From 3961038ddb6d9e14be0df085bca838da2774841f Mon Sep 17 00:00:00 2001 From: std-odoo Date: Thu, 7 Jul 2022 14:06:49 +0000 Subject: [PATCH] [IMP] web: create a properties field component Purpose ======= Create a component for the new Properties field. This component instantiates the right sub-component based on the property type (e.g. a DatePicker for a DateTime, a Many2XAutocomplete for relational properties, etc). This component also allow changing the properties definition (default value, model, property type, etc). Note that currently it is limited to children properties, assuming definition is done from children to containers. Add unit tests to ensure that behavior of the properties component is correct. Task-2852259 Part-of: odoo/odoo#95184 --- .../src/core/popover/popover_container.js | 4 + .../static/src/core/popover/popover_hook.js | 1 + .../src/core/popover/popover_service.js | 1 + .../src/legacy/js/fields/field_utils.js | 16 + .../src/views/basic_relational_model.js | 22 + .../views/fields/many2one/many2one_field.js | 2 +- .../views/fields/properties/icons/boolean.png | Bin 0 -> 1081 bytes .../views/fields/properties/icons/char.png | Bin 0 -> 1262 bytes .../views/fields/properties/icons/date.png | Bin 0 -> 1123 bytes .../fields/properties/icons/datetime.png | Bin 0 -> 1289 bytes .../views/fields/properties/icons/float.png | Bin 0 -> 979 bytes .../views/fields/properties/icons/integer.png | Bin 0 -> 1225 bytes .../fields/properties/icons/many2many.png | Bin 0 -> 1101 bytes .../fields/properties/icons/many2one.png | Bin 0 -> 853 bytes .../fields/properties/icons/selection.png | Bin 0 -> 599 bytes .../views/fields/properties/icons/tags.png | Bin 0 -> 805 bytes .../fields/properties/properties_field.js | 457 +++++++ .../fields/properties/properties_field.scss | 149 +++ .../fields/properties/properties_field.xml | 57 + .../fields/properties/property_definition.js | 370 ++++++ .../properties/property_definition.scss | 57 + .../fields/properties/property_definition.xml | 176 +++ .../property_definition_selection.js | 219 ++++ .../property_definition_selection.scss | 10 + .../property_definition_selection.xml | 46 + .../views/fields/properties/property_tags.js | 294 +++++ .../fields/properties/property_tags.scss | 25 + .../views/fields/properties/property_tags.xml | 27 + .../views/fields/properties/property_value.js | 365 ++++++ .../fields/properties/property_value.scss | 34 + .../fields/properties/property_value.xml | 129 ++ .../src/views/fields/relational_utils.js | 7 +- addons/web/static/src/views/utils.js | 12 + .../views/fields/properties_field_tests.js | 1100 +++++++++++++++++ 34 files changed, 3576 insertions(+), 4 deletions(-) create mode 100644 addons/web/static/src/views/fields/properties/icons/boolean.png create mode 100644 addons/web/static/src/views/fields/properties/icons/char.png create mode 100644 addons/web/static/src/views/fields/properties/icons/date.png create mode 100644 addons/web/static/src/views/fields/properties/icons/datetime.png create mode 100644 addons/web/static/src/views/fields/properties/icons/float.png create mode 100644 addons/web/static/src/views/fields/properties/icons/integer.png create mode 100644 addons/web/static/src/views/fields/properties/icons/many2many.png create mode 100644 addons/web/static/src/views/fields/properties/icons/many2one.png create mode 100644 addons/web/static/src/views/fields/properties/icons/selection.png create mode 100644 addons/web/static/src/views/fields/properties/icons/tags.png create mode 100644 addons/web/static/src/views/fields/properties/properties_field.js create mode 100644 addons/web/static/src/views/fields/properties/properties_field.scss create mode 100644 addons/web/static/src/views/fields/properties/properties_field.xml create mode 100644 addons/web/static/src/views/fields/properties/property_definition.js create mode 100644 addons/web/static/src/views/fields/properties/property_definition.scss create mode 100644 addons/web/static/src/views/fields/properties/property_definition.xml create mode 100644 addons/web/static/src/views/fields/properties/property_definition_selection.js create mode 100644 addons/web/static/src/views/fields/properties/property_definition_selection.scss create mode 100644 addons/web/static/src/views/fields/properties/property_definition_selection.xml create mode 100644 addons/web/static/src/views/fields/properties/property_tags.js create mode 100644 addons/web/static/src/views/fields/properties/property_tags.scss create mode 100644 addons/web/static/src/views/fields/properties/property_tags.xml create mode 100644 addons/web/static/src/views/fields/properties/property_value.js create mode 100644 addons/web/static/src/views/fields/properties/property_value.scss create mode 100644 addons/web/static/src/views/fields/properties/property_value.xml create mode 100644 addons/web/static/tests/views/fields/properties_field_tests.js diff --git a/addons/web/static/src/core/popover/popover_container.js b/addons/web/static/src/core/popover/popover_container.js index 16de55afb57..5c6b8234cf5 100644 --- a/addons/web/static/src/core/popover/popover_container.js +++ b/addons/web/static/src/core/popover/popover_container.js @@ -45,6 +45,10 @@ class PopoverController extends Component { if (this.target.contains(ev.target) || ev.target.closest(".o_popover")) { return; } + if (this.props.preventClose && this.props.preventClose(ev)) { + return; + } + if (this.props.closeOnClickAway) { this.props.close(); } diff --git a/addons/web/static/src/core/popover/popover_hook.js b/addons/web/static/src/core/popover/popover_hook.js index 2b0dcdb6474..d41d5a95b2f 100644 --- a/addons/web/static/src/core/popover/popover_hook.js +++ b/addons/web/static/src/core/popover/popover_hook.js @@ -25,6 +25,7 @@ export function usePopover() { * @param {Object} [options] * @param {boolean} [options.closeOnClickAway=true] * @param {function()} [options.onClose] + * @param {function()} [options.preventClose] * @param {string} [options.popoverClass] * @param {string} [options.position] * @returns {function()} diff --git a/addons/web/static/src/core/popover/popover_service.js b/addons/web/static/src/core/popover/popover_service.js index f30c1401e61..d21024518c5 100644 --- a/addons/web/static/src/core/popover/popover_service.js +++ b/addons/web/static/src/core/popover/popover_service.js @@ -41,6 +41,7 @@ export const popoverService = { position: options.position, popoverClass: options.popoverClass, closeOnClickAway: options.closeOnClickAway, + preventClose: options.preventClose, }; popovers[id] = popover; bus.trigger("UPDATE"); diff --git a/addons/web/static/src/legacy/js/fields/field_utils.js b/addons/web/static/src/legacy/js/fields/field_utils.js index 3c99114c8ff..96e96d6ce24 100644 --- a/addons/web/static/src/legacy/js/fields/field_utils.js +++ b/addons/web/static/src/legacy/js/fields/field_utils.js @@ -416,6 +416,20 @@ function formatSelection(value, field, options) { return value; } +/** + * Returns a string representing the value of the python properties field. + * + * @param {string|false} value + * @param {Object} [field] + * a description of the field (note: this parameter is ignored) + */ +function formatProperties(value, field) { + if (!value || !value.length) { + return ''; + } + return value.map(property => property['string']).join(', '); +} + //////////////////////////////////////////////////////////////////////////////// // Parse //////////////////////////////////////////////////////////////////////////////// @@ -749,6 +763,8 @@ return { monetary: formatMonetary, one2many: formatX2Many, percentage: formatPercentage, + properties: formatProperties, + properties_definition: formatProperties, reference: formatMany2one, selection: formatSelection, text: formatChar, diff --git a/addons/web/static/src/views/basic_relational_model.js b/addons/web/static/src/views/basic_relational_model.js index 851480b5a33..5f25eb4aacc 100644 --- a/addons/web/static/src/views/basic_relational_model.js +++ b/addons/web/static/src/views/basic_relational_model.js @@ -371,6 +371,11 @@ export class Record extends DataPoint { case "integer": case "monetary": continue; + case "properties": + if (!this.checkPropertiesValidity(fieldName)) { + this.setInvalidField(fieldName); + } + break; case "one2many": case "many2many": if (!(await this.checkX2ManyValidity(fieldName))) { @@ -449,6 +454,23 @@ export class Record extends DataPoint { return true; } + /** + * The label and the id of the properties are always required. + * + * @param {string} fieldName + * @returns {boolean} + */ + checkPropertiesValidity(fieldName) { + const value = this.data[fieldName]; + if (!value) { + return true; + } + return value.every((propertyDefinition) => + !propertyDefinition.id || + (propertyDefinition.string && propertyDefinition.string.length) + ); + } + setInvalidField(fieldName) { this._invalidFields.add(fieldName); const bm = this.model.__bm__; diff --git a/addons/web/static/src/views/fields/many2one/many2one_field.js b/addons/web/static/src/views/fields/many2one/many2one_field.js index 2f1797eac2f..3ba493f8c9f 100644 --- a/addons/web/static/src/views/fields/many2one/many2one_field.js +++ b/addons/web/static/src/views/fields/many2one/many2one_field.js @@ -23,7 +23,7 @@ class CreateConfirmationDialog extends Component { CreateConfirmationDialog.components = { Dialog }; CreateConfirmationDialog.template = "web.Many2OneField.CreateConfirmationDialog"; -function m2oTupleFromData(data) { +export function m2oTupleFromData(data) { const id = data.id; let name; if ("display_name" in data) { diff --git a/addons/web/static/src/views/fields/properties/icons/boolean.png b/addons/web/static/src/views/fields/properties/icons/boolean.png new file mode 100644 index 0000000000000000000000000000000000000000..717ca4f09635e2ac9a741db51881ca346298b676 GIT binary patch literal 1081 zcmV-91jhS`P)V>U4h#&rc_l?b?oJ93kx>c^NFXt}xPSpSU}RwUb`3-_FkIFREMeux zqZS+BECN~xq(1<$NIIC!$O2*~LD&)?c6mub36QM=VkZ|Af!IJ7Gw>9J1UUoQK0vlY zIubhxiJg*KoD5{=0NL>fzva(oV6gbYz`#5aA(k|afnj?)1B1jJgqX@g1_u6C28L~y z5n`YH!Tx0wJ5-WdQ34Y2(|2SjNGwVOOEZH4G%byR;nQ*k2Hr>p2H^_~3|!F=cYpzF zF)*M>3@1VW0CLzigrgrvvj6}932;bRa{vGkoB#k2bpfeya*+T40{%%vK~z|U?Uvt5 zQ(+j#zt1_xGQl6rn`SKUw7*~~+hT2#970(j{r*K6-Iw+QgXIUL?UDJq9`87S0fpZ^-Dzbev?$WZ{>56kuxXr)fHz4iM|SwxGNkU`JxOp**xc% z`Dnhh#nm0s@x0 z+U^(Oz#EWo7yzp$(|lQl&=C&bT2_WeLy-Y6x9Ne?1tLXoRVJmvQ%AZ^%9 zpwA#0+iEtQSLU3~NpwWR!B;tJ9iDE;@<6URHN~S7IavzoB+MvJr{ocbW9MJT z*KBinA#;Br>*i9yt&YK4lAM0%^?I`xz_z2UQ6{>twT)@fnigW_=7pu?AOJX>9W|*` zN>>}xqP71AWj0i@N&MyDt7V>U4h#&rc_l?b?oJ93kx>c^NFXt}xPSpSU}RwUb`3-_FkIFREMeux zqZS+BECN~xq(1<$NIIC!$O2*~LD&)?c6mub36QM=VkZ|Af!IJ7Gw>9J1UUoQK0vlY zIubhxiJg*KoD5{=0NL>fzva(oV6gbYz`#5aA(k|afnj?)1B1jJgqX@g1_u6C28L~y z5n`YH!Tx0wJ5-WdQ34Y2(|2SjNGwVOOEZH4G%byR;nQ*k2Hr>p2H^_~3|!F=cYpzF zF)*M>3@1VW0CLzigrgrvvj6}932;bRa{vGkoB#k2bpfeya*+T41G7m)K~z|U?Ur3= zTV)u>|IbO%WTxx7wSum5ICU^4;$WDpUD6$T(%=EvU0{@bv5T(uZm5^axe)QviC%0f z&fynCM5bLOG$YiE?n7Z0R&Z=0E8;F($J*MDBYXNDSPw3ARklslMgbWV6|l0G{^T zeFL7&7fk|>JAI@&`fr*yqJ{xQsX;)-oIVme<7^Y@EJgU74mW<6n&#m`1+69+=eEoDSJQ!;}?p#izH4uV}$hW z-n(~gBXtOAiY1IMp>Q0ePGS?ugaVGum1nPX?J=J73}~8WSb;}G3~V#q;?#ZDU)TXxdve0 z!cUwz9uCX&rSI}z0l-)`J7*e+mjt+0g~Yaro3{>l0;IwqC|?wdL$|Bm&)-3gI(@r049Mk;`yLRQz4(huO0S4aVVp%V~% z(ivZNf_B9o{QDK01Tf@$NbP-Rt^)SOO|!)-AR*Ikhq!9gonR`JS~oS%K27l2g<}4A zZCuw6M`p_t8Gv^$e=>^c(g6VPdI1!0JzX}&o$(-L3pzDVts%*DQ?H*G02c5}Y zgSK(vj6W3Y-Z5JqEsNm126^2EUj=0s1VaEq0;Wg=A@WV2i34Nj&i%Hu z%qmLPt>CO(JQjFl{X1jX>>L1e_Vje9O5kZwZ4hV^&0+pHGBR>!H936w^5x5yuLoFv Y0XODo{6Kt6g8%>k07*qoM6N<$f};sLL;wH) literal 0 HcmV?d00001 diff --git a/addons/web/static/src/views/fields/properties/icons/date.png b/addons/web/static/src/views/fields/properties/icons/date.png new file mode 100644 index 0000000000000000000000000000000000000000..55c36fc0444a3ad857a63c91f5e4163e2a6ab649 GIT binary patch literal 1123 zcmV-p1f2VcP)V>U4h#&rc_l?b?oJ93kx>c^NFXt}xPSpSU}RwUb`3-_FkIFREMeux zqZS+BECN~xq(1<$NIIC!$O2*~LD&)?c6mub36QM=VkZ|Af!IJ7Gw>9J1UUoQK0vlY zIubhxiJg*KoD5{=0NL>fzva(oV6gbYz`#5aA(k|afnj?)1B1jJgqX@g1_u6C28L~y z5n`YH!Tx0wJ5-WdQ34Y2(|2SjNGwVOOEZH4G%byR;nQ*k2Hr>p2H^_~3|!F=cYpzF zF)*M>3@1VW0CLzigrgrvvj6}932;bRa{vGkoB#k2bpfeya*+T411L#EK~z|U?UqkS zR6!iazrT5}(l98ZEQ^GoIs}19uJK&kT9A zE(ME!*8c38t3gmn9fBf?NJ@w@Q+M}$JLucqmbUJ`bzQ;_JeYZJels6t-pp@+d7L51 z8k8BkXs`G8bSI6;WPi%ZVA&w&1^=2}!{;lCj&d7_LI5DmmV^LN$D9l}ZGVD6Jx`wW zbasB6QjnySyS?WC)RWMiNH`!Of=l6GpixAaAn2;_){?w3M`8&mk-Bgwa2c@ugCvj4 zol+tsk6LB>CfPIxE>l9AOI%PS`I(qS;x0LV%uKsmoo0+w`7^^<*k(|2X^G?H{1VR# zIwj?lw!N~dBxdrAkR^aDhVV6@wSnU0WlF4A5kQC6_KU795LxEd{t704VBoIN1HP z_fgji0Id^#(0|6>Zv+5M(-Z_>L~uhy>a!xKLw~7hx}j;hQMfe!zV)6WX1vit3<5yW zilxtsS1(`H7wSHw=~}xJ!5MFoV9@^=z-RkAW6z!?(V^km$%|W{FAx|6fVQ@_aVJuC z(mrOInwnH}=*LY0#UdC57Sv@*%8pHtDWy7pSMVCay_4M>?+>*<%cPh^S~KV}ycbE< z0Duyl>I-$<$gI|HQWh+8dm9O!1OSL?4u`rgWZcR|N}0q};XOf<%>bZ8TJ!W}=RKbN z1=jcc{Nae-?;p#g;EZR`@cD{JUbX5bd3Yo;^xggpeH(Ahq}-o^|J)>zF<@Q4k8%U7HIK{hh`N%ynFL$ZSnd$0$C%FWdI7M>|wUV pAcGL;R7xG_eb_aS&9VP)egPCC6guCxUdsRg002ovPDHLkV1fgS>-GQu literal 0 HcmV?d00001 diff --git a/addons/web/static/src/views/fields/properties/icons/datetime.png b/addons/web/static/src/views/fields/properties/icons/datetime.png new file mode 100644 index 0000000000000000000000000000000000000000..2314b7f3a39e3b77b6e70e07c15debc8fe02af56 GIT binary patch literal 1289 zcmV+k1@`)hP)V>U4h#&rc_l?b?oJ93kx>c^NFXt}xPSpSU}RwUb`3-_FkIFREMeux zqZS+BECN~xq(1<$NIIC!$O2*~LD&)?c6mub36QM=VkZ|Af!IJ7Gw>9J1UUoQK0vlY zIubhxiJg*KoD5{=0NL>fzva(oV6gbYz`#5aA(k|afnj?)1B1jJgqX@g1_u6C28L~y z5n`YH!Tx0wJ5-WdQ34Y2(|2SjNGwVOOEZH4G%byR;nQ*k2Hr>p2H^_~3|!F=cYpzF zF)*M>3@1VW0CLzigrgrvvj6}932;bRa{vGkoB#k2bpfeya*+T41I|fAK~z|U?U&DQ z8&wd;KeOvdrL79#f<#gwC{-%u5H4ih_(xo?-KZzH&|Em;pmNC}NFXlsgnHpeks2w< zUl5SEgLhNcaflr*MM6+feh4M4=&33xX(09vhsf@xwd1Uv!~yX=y|?>j=A-v^-ps=P zYERN^!C)k*{`u0vTvMCf^k=jaY(I410Uz7mNILy&d0k&dc?Lk$o7!R^#+&T~Z0Jw8 z6FMZ{EM&8{>jJ8(gk$yxz}u>X+JR_88zOvIoOj<75#0i4MeT7_{iH)+TV#k#6btT0 z;QIzu^^MLUZI!BKs$+kW^mTxxuGGg;0~Bd~C+?$aEC>FYX_&3;R*ax}w8B*7+d(-< zM_g0e_gy%eN_ncIpfM48P*mZz2zf)y*~R&++Yvx3o<6BwWyVu)7(n2GBFhM%99Pv$ zMJpLAYUhklD6^RR@~2kfw3H-6@w0KfN(p6HkgH;vBLn>}KDktI`<4oB-%I@`o)Xg> z5tviegje2J8XUG`t;F(IlCr9r)p?^NvEu2Ic$E^!F<`t@aIb6^z>3;$fnEkyjZoKM zqn&KjhWRSV&hA0=Dl@8d*Mqa7_QcSzecJ!5RB$Ij##QO|%H^4z#PBQ75r?CxRP|Wv z?VQ3yrNYpo&r1b&vU=R7V`+QO02S{%0H$#%h~1{-5`d?b(I8gA0F}7FgMm}eUkzdh zFwlP@2ePJ0JcwN&KoNRWWO;gedLs}WuxX>qBJ>2Y+tpeuiU*vJL`M2|%cl=en`W^p zw@`Y=#>Tqp0-BOW^?q44u3NFx$JV)X$ExoViHjjbdR4jAB4BfgkOw{d?Tug0)O%md zJ3o{b=4>(Kbv%8IKmPtP96f6Rtd|Q3RF7=>*egJc&j6@Y-q~(lG4IYk(e?BxVCv{0 z?*@Q}_bz}T=IovU77LDpa7`h(Ss@xLm;3yFX~BKpah!YMSn2|bEh4#~%|qZM5yQ-g za2ND)Gf04NEOkNE`&5LxMkthNwthRn9hJILK6}$NLazd=s(QtWBwZ_Fr~QeONL&ns zqp8$jH0gMrHwmn&DX+BB(X`ZVeVzX*Z^hFVn{_z}n!Nt~&wma^k}CqbR-QkO>Jb1D zX{+68xgRX$vp0c6tzp%N4J)cE9dB5xDi$0EaN70LNdKn4I#~lfT%B1_d#awnUP;y8 zugY3=R09+!DV*^cjO`QKV-mDj9oLT7Fl7RfzdD2TP$I0GUFJk}Ui$}G`R$kAd!KvZ z3qg7W=?0GOD;RPIk*r}Dql?#^@AfnHe_Q_mPr`|y=(LN#00000NkvXXu0mjfa!XJC literal 0 HcmV?d00001 diff --git a/addons/web/static/src/views/fields/properties/icons/float.png b/addons/web/static/src/views/fields/properties/icons/float.png new file mode 100644 index 0000000000000000000000000000000000000000..f2beed07f4f780eb69012fc13d3da691b6cc0c3d GIT binary patch literal 979 zcmV;^11$WBP)V>U4h#&rc_l?b?oJ93kx>c^NFXt}xPSpSU}RwUb`3-_FkIFREMeux zqZS+BECN~xq(1<$NIIC!$O2*~LD&)?c6mub36QM=VkZ|Af!IJ7Gw>9J1UUoQK0vlY zIubhxiJg*KoD5{=0NL>fzva(oV6gbYz`#5aA(k|afnj?)1B1jJgqX@g1_u6C28L~y z5n`YH!Tx0wJ5-WdQ34Y2(|2SjNGwVOOEZH4G%byR;nQ*k2Hr>p2H^_~3|!F=cYpzF zF)*M>3@1VW0CLzigrgrvvj6}932;bRa{vGkoB#k2bpfeya*+T40+>leK~z|U?Uui5 z6JZ#~zt3HoR;{AeN)d`31fdiM3r*7`r8cQ82yPu597HIBgQB~Li<&`P6h;34<+j?P zjWqpnwT%@;q)Y3dwv>Pxu|h8~%{`BcX^!Sn6HKDReWrVUeDC@4zW2Ty;Nalk;Nb8# zP@zJ1_sQlB<^Yo%5fK6a1+LN9Oyp%L1H6GyzbV|gU5^5!Vl&a(b~V@5gvZ}^7GmB= znVA*U&eX_#3VX>Qv4O4GEo$KU%+fU+1A7w*kZjq9ao@(j*Sjn4rOoMK@q+w03ZwoAVwzD5eQv7+_dkGrfH^~ z#^w}P4-wRXIlLTCJSx;v9fL$GM08&vx=KVB0p-E(TOw=poW~dJvy)bQi``Je)B5NY zA`)xfcVggfCcQB)kUanZk_!NyZ`D_P1$BKazSSH6yqFkW1n@8$3#iRb|4+X`Qs~3x zGvtt6Pg%A&7z{S7|CFxWWUR*Y=$k+3m~qS!F530v8T1Yg?x_6&H5(^0iOrf2xqEsB z>$7oVBa)h?nMPV^V=;$w{^$vSGv5CG{ib1hcdGue$;s;(>zs~2=oFaGTT<_eGRX0S zZV1}#n!y}4I1$cpn2+Z=$*2Kt6=kp@ z2o4A&WQjK+B9lli&FIl3J84A^iCRZ%1e&nkc~%HCA#kV>U4h#&rc_l?b?oJ93kx>c^NFXt}xPSpSU}RwUb`3-_FkIFREMeux zqZS+BECN~xq(1<$NIIC!$O2*~LD&)?c6mub36QM=VkZ|Af!IJ7Gw>9J1UUoQK0vlY zIubhxiJg*KoD5{=0NL>fzva(oV6gbYz`#5aA(k|afnj?)1B1jJgqX@g1_u6C28L~y z5n`YH!Tx0wJ5-WdQ34Y2(|2SjNGwVOOEZH4G%byR;nQ*k2Hr>p2H^_~3|!F=cYpzF zF)*M>3@1VW0CLzigrgrvvj6}932;bRa{vGkoB#k2bpfeya*+T41CB{VK~z|U?Ur3= zTV))^pC_NQ+D(eRa8!h$;t*NAS)y%cXfq^2${dQa4e4o4FuSA$FmTfG;x;jtJ$@_SbmL?}Bp&!8y$nPrrpa1)w-+52o zb6%iBhYlS&{HNi+b>T=X(FeymD==0SQ5J#lSTUPkuI+0OJpA~`5Gy?BIV*Len9F?S z6(y6&uB(6CI@QuDtjS^~b0rc>^uaFwAgHHah9v|;hiWAQ4GsUil%Qjs1#n_h+Jk}7 z(Qfv^os;hJN1h{ zyyqo=WdQK|_;#(_cJUP4OgZjZ7w&Q_>)T{9+2s{~H+$hSfS&=HhaWuflRGDr1pqkZ zR3w(@t8SQbsxI4#Amv-G4&o$0p!eazWk`l75CE3=J zqy)hd0^zZ(3)0d0j|=eOU3V_%+34W#X|J&Y{t{t%WyNfI8NtvY65}9OK(3G&ckf`p zV;47LF_$?L860^;($lqdBc6#43}0F(%%FKo~c#L4Fz7coAn8|_B(e8g#(2IP2=2rmEk*w6# zB^`9G-~7DwgWW#c76gD|A#)Kx!{!h$8a)z!f7ezu>_`v*iuufk0Kah8W%N#TAbxyz zTAS=x5CHu9dtM>=y{k;odDn`TRbB>Z0W`H6{JLGURM*J|q$?vUhtB5J1~sXYK7 zgWH?+H-YE^Ax=13i*S}WjH>UEuk&ab8wbxQP9~m6^CV>U4h#&rc_l?b?oJ93kx>c^NFXt}xPSpSU}RwUb`3-_FkIFREMeux zqZS+BECN~xq(1<$NIIC!$O2*~LD&)?c6mub36QM=VkZ|Af!IJ7Gw>9J1UUoQK0vlY zIubhxiJg*KoD5{=0NL>fzva(oV6gbYz`#5aA(k|afnj?)1B1jJgqX@g1_u6C28L~y z5n`YH!Tx0wJ5-WdQ34Y2(|2SjNGwVOOEZH4G%byR;nQ*k2Hr>p2H^_~3|!F=cYpzF zF)*M>3@1VW0CLzigrgrvvj6}932;bRa{vGkoB#k2bpfeya*+T40}@F@K~z|U?Uzkx zTvZr`pZDBMCe|V*Xe-!SaA8$YsL+{6W;D$-BE^NEX;%daDG0?stfhkDN@)G5S#+VI zdv$Q3qatP|#$-k(qi%#+%%a*x;v%L6f5s$}IiCv|m1^tU+^IyIXLatq-}ip+{m$i_ zd!S95Hf{djs4+dB;`!`tg20OwgBvJ!sWh!*U+~Ly1*k{e; zq*9sdYVe?8duVEc{~&w^jmr(Yr&jaR$<)Bh{r&fKT_`-6$~?3TUzpi`j4Q&H3vDpA zF1Q-Grx7(X_We>j{nq(R_NFpN%xo01xRYLub#`8V(G8Zy#^x%;{1eV;o6wxW9o3cD z?74BtRA#?7I|$SeyUWGGNa@4(zq;rK8FW*M>+YSInW;t5Z^=3+r^f&=_Il5u-|q2bE|;rD)ozKw z@?`!5;v>T@1GXZa+`O^pwN_}iY$uWQ;_ny6Gudpm-U{888FaY$##qwv*6Fs0y7`A2 zW0Q@vN;mQ{1JIiu?DM`}1XY19$(Gm0wW&}jEJW3g+`*xtp}>3p0)UgAMZ9Z9*N=-F zj;akUFgW|&!XdEjNT<6xx}WQW0}_j1Be$jpGg0*;{ZS`x8{CEWZ=0D5Trl{_)K~!^ zkOG_yeHsfIa#Em!qCFEoYO=9T58$-W(T zZe4Ai&>eJvyV1NIFtcF*=iIUKRQ`c;l^+-odyXX-VQ}5R1RGvb6Y3Gj&$o zL0|V@n%OYYl9L|&J97XN6WO0Oc3-*+^a@7IyglBmxjz$rip9Kh?wFjrsaz}^y-;W_ zm#bDvg?&Bd_&lr>EZ{U1ACzPPpQG0Kh>>ygKgTh`LFXUvO6ck TTbwRw00000NkvXXu0mjf3rGzL literal 0 HcmV?d00001 diff --git a/addons/web/static/src/views/fields/properties/icons/many2one.png b/addons/web/static/src/views/fields/properties/icons/many2one.png new file mode 100644 index 0000000000000000000000000000000000000000..c66bbbbbf4e7ec7efd994d32a69df77bc874c8db GIT binary patch literal 853 zcmV-b1FHOqP)V>U4h#&rc_l?b?oJ93kx>c^NFXt}xPSpSU}RwUb`3-_FkIFREMeux zqZS+BECN~xq(1<$NIIC!$O2*~LD&)?c6mub36QM=VkZ|Af!IJ7Gw>9J1UUoQK0vlY zIubhxiJg*KoD5{=0NL>fzva(oV6gbYz`#5aA(k|afnj?)1B1jJgqX@g1_u6C28L~y z5n`YH!Tx0wJ5-WdQ34Y2(|2SjNGwVOOEZH4G%byR;nQ*k2Hr>p2H^_~3|!F=cYpzF zF)*M>3@1VW0CLzigrgrvvj6}932;bRa{vGkoB#k2bpfeya*+T40vbs~K~z|U?Uui5 z6JZ#~KhH%Pj5c5*4jBU?#UaJPG|dg6v4ukGWJf2##l?T4PIYiF;N~K=lgdG91kqB- z*!}@7QjjDnNt@UtcaMWjE2Xwxd}~jC2>0mkjnxE&+{gME4zlviFov;<2XAe0MGb6u(r6^20rdS>%bB~&MwY) z$`eJWzBG641M?;TjTOn-yYfpcxeK5$Ys%MXPb(IS$>uk$3DQW!hb|o!X21Q`XqzdQ z%XJZ1S2X^)*?F3so{s;ir|%p*UUCm%MOAM}qcc91pLix#AlQh)?uh9WP9d_IloJfolpWK~t)n_X8OCtL_AHa6iPouA2eqm+c@| zm2tqVeZ%%}S5TsYYzlg?NVZND5cCnMQ4x90@X($2j?*}>!B^fJEs{cE_F}8MT|KH% z`_g0U*j*~J+=EddC;Fy-qz2Xk|CXQ|f{URRPok6W(l-neyP zIJ$6ZfbM3!^<$-g?zax1hFA|WBk2W`1M2`)eDug)d20%7K)}js`P`xJ4TJR+_^U4P ffT2Q#`q%Xx{(atODCBPc00000NkvXXu0mjf#?Eq5 literal 0 HcmV?d00001 diff --git a/addons/web/static/src/views/fields/properties/icons/selection.png b/addons/web/static/src/views/fields/properties/icons/selection.png new file mode 100644 index 0000000000000000000000000000000000000000..8e38c27fad0509217fe07a9bf6a20657bc11e56a GIT binary patch literal 599 zcmV-d0;v6oP)V>U4h#&rc_l?b?oJ93kx>c^NFXt}xPSpSU}RwUb`3-_FkIFREMeux zqZS+BECN~xq(1<$NIIC!$O2*~LD&)?c6mub36QM=VkZ|Af!IJ7Gw>9J1UUoQK0vlY zIubhxiJg*KoD5{=0NL>fzva(oV6gbYz`#5aA(k|afnj?)1B1jJgqX@g1_u6C28L~y z5n`YH!Tx0wJ5-WdQ34Y2(|2SjNGwVOOEZH4G%byR;nQ*k2Hr>p2H^_~3|!F=cYpzF zF)*M>3@1VW0CLzigrgrvvj6}932;bRa{vGkoB#k2bpfeya*+T40USw0K~z|U?UJFA z!Y~v??+dO9SFy7*fLyf(sHuS;;18%;!B_03Y`=p6Q&rZis%3@&3?4}^Fq`JZYBp11VIo4;gwL+;V7N{;G*YGxmi)ZDOQ;^)^r$mqXiLFt(Ce#L{;347EW1P zV4O}r^aa0wj>4+}0G&x;SQaa%hj(m&!)|+e@AZ#bV-{WxPzqmVv0l37tpx#KzuW%3 z^#*-teDbRSP?VR&`rB*7H5lrnk9V~>pD^D?KkrPOzW5H;M_=zuyb1yg9~s`&#yLZ- zK61UQ?cfgDedK#*;{C;UY(9o~XTmNBH24_kU2Rq9f=3_Q@Xmzopvk|FKpWmYg(RJ1 lNjk}1-9r!rK@fy?xdKk6a8xvZTZ{kz002ovPDHLkV1mvt?T7#X literal 0 HcmV?d00001 diff --git a/addons/web/static/src/views/fields/properties/icons/tags.png b/addons/web/static/src/views/fields/properties/icons/tags.png new file mode 100644 index 0000000000000000000000000000000000000000..c26c3de940544fe6548f48ebb2369a7a9a8de9e7 GIT binary patch literal 805 zcmV+=1KRwFP)V>U4h#&rc_l?b?oJ93kx>c^NFXt}xPSpSU}RwUb`3-_FkIFREMeux zqZS+BECN~xq(1<$NIIC!$O2*~LD&)?c6mub36QM=VkZ|Af!IJ7Gw>9J1UUoQK0vlY zIubhxiJg*KoD5{=0NL>fzva(oV6gbYz`#5aA(k|afnj?)1B1jJgqX@g1_u6C28L~y z5n`YH!Tx0wJ5-WdQ34Y2(|2SjNGwVOOEZH4G%byR;nQ*k2Hr>p2H^_~3|!F=cYpzF zF)*M>3@1VW0CLzigrgrvvj6}932;bRa{vGkoB#k2bpfeya*+T40qRLaK~z|U?bR_) z+dvct@c%nvERYhhFd(5U9Xc`~0aBDSMaocy4E_k+7%G*uQ$IpJ0R0LXqe@9c6q$-J zl@&w)=wgz{ThD*-|9`H~YQ2`{_B3fjoKg%yvjss5G0!$=!X6!5$b( zksA>v08Rk$dF1zeTR@>IOz!Sm%=}RY2LQ|{Fk_v;z0F!e%m>pYB(yAC?0d-01C20SV!a zhTV=SP(8uF;e~qrYvJkY(LqUjcMFq@o?zIv?~3^^j}A(chu|9+@8X?eK1ofWg%~0= z9j zf^=m6gxQr+wp3*$aOC%V62i*@N#M7t+SHe1H0*Y=Kr+#xsuuYcr;Q}gSQMOHRbA_E zCM_g^#tz~A(ct@qx+g|9YmFV2vJGa}7S8wkr_=jl#s*Rs7xo$?2{H~Y>^F=FGA=G$ zlOS=BCb)22!ssAPap77EnTAY)3)fr9YsjQH0GPAG1FkQBovoJN42XD*fFg(vgyVQW jhTU^pXXMF~XBP7tOGgbT17t)000000NkvXXu0mjfXj)NX literal 0 HcmV?d00001 diff --git a/addons/web/static/src/views/fields/properties/properties_field.js b/addons/web/static/src/views/fields/properties/properties_field.js new file mode 100644 index 00000000000..364022f67ae --- /dev/null +++ b/addons/web/static/src/views/fields/properties/properties_field.js @@ -0,0 +1,457 @@ +/** @odoo-module **/ + +import { _lt } from "@web/core/l10n/translation"; +import { registry } from "@web/core/registry"; +import { standardFieldProps } from "../standard_field_props"; +import { uuid } from "../../utils"; +import { PropertyDefinition } from "./property_definition"; +import { Dropdown } from "@web/core/dropdown/dropdown"; +import { DropdownItem } from "@web/core/dropdown/dropdown_item"; +import { PropertyValue } from "./property_value"; +import { useService } from "@web/core/utils/hooks"; +import { usePopover } from "@web/core/popover/popover_hook"; +import { sprintf } from "@web/core/utils/strings"; +import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; + +const { + Component, + useRef, + useState, + useEffect, + onWillStart, + onWillUpdateProps, +} = owl; + +export class PropertiesField extends Component { + setup() { + this.notification = useService("notification"); + this.orm = useService("orm"); + this.dialogService = useService("dialog"); + this.popover = usePopover(); + this.propertiesRef = useRef("properties"); + + this.state = useState({ + canChangeDefinition: true, + movedPropertyName: null, + }); + + this._saveInitialPropertiesValues(); + + const field = this.props.record.fields[this.props.name]; + this.definitionRecordField = field.definition_record; + + onWillStart(async () => { + await this._checkDefinitionAccess(); + }); + + onWillUpdateProps(async () => { + // reset the initials properties values if we saved the record + if (this.props.record.mode === "readonly") { + this._saveInitialPropertiesValues(); + } + }); + + useEffect(() => { + this._movePopoverIfNeeded(); + + if (this.openLastPropertyDefinition) { + this.openLastPropertyDefinition = null; + const propertiesList = this.propertiesList; + const lastPropertyName = + propertiesList[propertiesList.length - 1].name; + const labels = this.propertiesRef.el.querySelectorAll( + `.o_property_field[property-name="${lastPropertyName}"] .o_field_property_open_popover` + ); + const lastLabel = labels[labels.length - 1]; + lastLabel.click(); + } + }); + } + + /* -------------------------------------------------------- + * Public methods / Getters + * -------------------------------------------------------- */ + + /** + * Return the current context + * + * @returns {object} + */ + get context() { + return this.props.record.getFieldContext(this.props.name); + } + + /** + * Return the current properties value. + * + * Make a deep copy of this properties values, so when we will modify it + * in the events, we won't re-use same object (can lead to issue, e.g. if we + * discard a form view, we should be able to restore the old props). + * + * @returns {array} + */ + get propertiesList() { + const propertiesValues = JSON.parse( + JSON.stringify(this.props.value || []) + ); + return propertiesValues.filter( + (definition) => !definition.definition_deleted + ); + } + + /** + * Return false if we should not close the popover containing the + * properties definition based on the event received. + * + * If we edit the datetime, it will open a popover with the date picker + * component, but this component won't be a child of the current popover. + * So when we will click on it to select a date, it will close the definition + * popover. It's the same for other similar components (many2one modal, etc). + * + * @param {event} event + * @returns {boolean} + */ + checkPopoverClose(event) { + if (document.activeElement.closest(".o_field_property_definition")) { + // the focus is still on an element of the definition + return true; + } + + if (event.target.closest(".bootstrap-datetimepicker-widget")) { + // selected a datetime, do not close the definition popover + return true; + } + + if (event.target.closest(".modal")) { + // close a many2one modal + return true; + } + + return false; + } + + /* -------------------------------------------------------- + * Event handlers + * -------------------------------------------------------- */ + + /** + * Move the given property up or down in the list. + * + * @param {string} propertyName + * @param {string} direction, either "up" or "down" + */ + onPropertyMove(propertyName, direction) { + const propertiesValues = this.propertiesList || []; + const propertyIndex = propertiesValues.findIndex( + (property) => property.name === propertyName + ); + + const targetIndex = propertyIndex + (direction === "down" ? 1 : -1); + if (targetIndex < 0 || targetIndex >= propertiesValues.length) { + this.notification.add( + direction === "down" + ? _lt("This field is already last") + : _lt("This field is already first"), + { type: "warning" } + ); + return; + } + this.state.movedPropertyName = propertyName; + + const prop = propertiesValues[targetIndex]; + propertiesValues[targetIndex] = propertiesValues[propertyIndex]; + propertiesValues[propertyIndex] = prop; + propertiesValues[propertyIndex].definition_changed = true; + this.props.update(propertiesValues); + // move the popover once the DOM is updated + this.shouldUpdatePopoverPosition = true; + } + + /** + * The value / definition of the given property has been changed. + * `propertyValue` contains the definition of the property with the value. + * + * @param {string} propertyName + * @param {object} propertyValue + */ + onPropertyValueChange(propertyName, propertyValue) { + const propertiesValues = this.propertiesList; + propertiesValues.find( + (property) => property.name === propertyName + ).value = propertyValue; + this.props.update(propertiesValues); + } + + /** + * Open the popover with the property definition. + * + * @param {event} event + * @param {string} propertyName + */ + onPropertyEdit(event, propertyName) { + if (event.target.classList.contains("disabled")) { + event.stopPropagation(); + event.preventDefault(); + // remove the glitch if we click on the edit button + // while the popover is already opened + return; + } + + event.target.classList.add("disabled"); + + this.popoverCloseFn = this.popover.add( + event.currentTarget, + PropertyDefinition, + { + readonly: + this.props.readonly || !this.state.canChangeDefinition, + canChangeDefinition: this.state.canChangeDefinition, + propertyDefinition: this.propertiesList.find( + (property) => property.name === propertyName + ), + context: this.context, + onChange: this.onPropertyDefinitionChange.bind(this), + onDelete: () => this.onPropertyDelete(propertyName), + onPropertyMove: (direction) => + this.onPropertyMove(propertyName, direction), + }, + { + preventClose: this.checkPopoverClose, + popoverClass: "o_property_field_popover", + position: "top", + onClose: () => { + this.state.movedPropertyName = null; + event.target.classList.remove("disabled"); + }, + } + ); + } + + /** + * The property definition or value has been changed. + * + * @param {object} propertyDefinition + */ + onPropertyDefinitionChange(propertyDefinition) { + propertyDefinition["definition_changed"] = true; + const propertiesValues = this.propertiesList; + const propertyIndex = propertiesValues.findIndex( + (property) => property.name === propertyDefinition.name + ); + + // if the type / model are the same, restore the original name to not reset the children + // otherwise, generate a new value so all value of the record are reset + const initialValues = this.initialValues[propertyDefinition.name]; + if ( + initialValues && + propertyDefinition.type === initialValues.type && + propertyDefinition.comodel === initialValues.comodel + ) { + // restore the original name + propertyDefinition.name = initialValues.name; + } else if ( + initialValues && + initialValues.name === propertyDefinition.name + ) { + // generate a new new to reset all values on other records + // store the new generated name to be able to restore it + // if needed + const newName = uuid(); + this.initialValues[newName] = initialValues; + propertyDefinition.name = newName; + } + + propertiesValues[propertyIndex] = propertyDefinition; + this.props.update(propertiesValues); + } + + /** + * Mark a property as "to delete". + * + * @param {string} propertyName + */ + onPropertyDelete(propertyName) { + const dialogProps = { + title: _lt("Delete Property Field"), + body: sprintf( + _lt( + 'Are you sure you want to delete this property field? It will be removed for everyone using the "%s" %s.' + ), + this.parentName, + this.parentString + ), + confirm: () => { + if (this.popoverCloseFn) { + this.popoverCloseFn(); + this.popoverCloseFn = null; + } + const propertiesDefinitions = this.propertiesList; + propertiesDefinitions.find( + (property) => property.name === propertyName + ).definition_deleted = true; + this.props.update(propertiesDefinitions); + }, + cancel: () => {}, + }; + this.dialogService.add(ConfirmationDialog, dialogProps); + } + + onPropertyCreate() { + const propertiesDefinitions = this.propertiesList || []; + + if ( + propertiesDefinitions.length && + propertiesDefinitions.some( + (prop) => !prop.string || !prop.string.length + ) + ) { + // do not allow to add new field until we set a label on the previous one + this.propertiesRef.el + .closest(".o_field_properties") + .classList.add("o_field_invalid"); + + this.notification.add( + _lt("Please complete your properties before adding a new one"), + { type: "warning" } + ); + return; + } + + this.propertiesRef.el + .closest(".o_field_properties") + .classList.remove("o_field_invalid"); + + propertiesDefinitions.push({ + name: uuid(), + string: sprintf( + _lt("Property %s"), + propertiesDefinitions.length + 1 + ), + type: "char", + definition_changed: true, + }); + this.openLastPropertyDefinition = true; + this.props.update(propertiesDefinitions); + } + + /** + * The tags list has been changed. + * If `newValue` is given, update the property value as well. + * + * @param {string} propertyName + * @param {array} newTags + * @param {array | null} newValue + */ + onTagsChange(propertyName, newTags, newValue = null) { + const propertyDefinition = this.propertiesList.find( + (property) => property.name === propertyName + ); + propertyDefinition.tags = newTags; + if (newValue !== null) { + propertyDefinition.value = newValue; + } + propertyDefinition.definition_changed = true; + this.onPropertyDefinitionChange(propertyDefinition); + } + + /* -------------------------------------------------------- + * Private methods + * -------------------------------------------------------- */ + + /** + * Move the popover to the given property id. + * Used when we change the position of the properties. + * + * We change the popover position after the DOM has been updated (see @useEffect) + * because if we update it after changing the component properties, + */ + _movePopoverIfNeeded() { + if (!this.shouldUpdatePopoverPosition) { + return; + } + this.shouldUpdatePopoverPosition = false; + + const propertyName = this.state.movedPropertyName; + const popover = document + .querySelector(".o_field_property_definition") + .closest(".o_popover"); + const targetElement = document.querySelector( + `.o_property_field[property-name="${propertyName}"]` + ); + const targetPosition = targetElement.getBoundingClientRect(); + const popoverPosition = popover.getBoundingClientRect(); + + popover.style.top = + targetPosition.top - popoverPosition.height - 10 + "px"; + popover.style.left = targetPosition.left + "px"; + popover.style.position = "absolute"; + } + + /** + * Verify that we can write on the parent record, + * and therefor update the properties definition. + */ + async _checkDefinitionAccess() { + const definitionRecordId = + this.props.record.data[this.definitionRecordField][0]; + this.parentName = this.props.record.data[this.definitionRecordField][1]; + const definitionRecordModel = + this.props.record.fields[this.definitionRecordField].relation; + this.parentString = + this.props.record.fields[this.definitionRecordField].string; + + if (!definitionRecordId || !definitionRecordModel) { + return; + } + + // check if we can write on the definition record + this.state.canChangeDefinition = await this.orm.call( + definitionRecordModel, + "check_access_rights", + ["write", false] + ); + } + + /** + * If we change the type / model of a property, we will regenerate it's name + * (like if it was a new property) in order to reset the value of the children. + * + * But if we reset the old model / type, we want to be able to discard this + * modification (even if we save) and restore the original name. + * + * For that purpose, we save the original properties values. + */ + _saveInitialPropertiesValues() { + // initial properties values, if the type or the model changed, the + // name will be regenerated in order to reset the value on the children + this.initialValues = {}; + for (let propertiesValues of this.props.value || []) { + this.initialValues[propertiesValues.name] = { + name: propertiesValues.name, + type: propertiesValues.type, + comodel: propertiesValues.comodel, + }; + } + } +} + +PropertiesField.template = "web.PropertiesField"; +PropertiesField.components = { + Dropdown, + DropdownItem, + PropertyDefinition, + PropertyValue, +}; +PropertiesField.props = { + ...standardFieldProps, + columns: { type: Number, optional: true }, +}; +PropertiesField.extractProps = ({ attrs, field }) => { + const columns = parseInt(attrs.columns || "1"); + return { columns }; +}; + +PropertiesField.displayName = _lt("Properties"); +PropertiesField.supportedTypes = ["properties"]; + +registry.category("fields").add("properties", PropertiesField); diff --git a/addons/web/static/src/views/fields/properties/properties_field.scss b/addons/web/static/src/views/fields/properties/properties_field.scss new file mode 100644 index 00000000000..1ea5f54585e --- /dev/null +++ b/addons/web/static/src/views/fields/properties/properties_field.scss @@ -0,0 +1,149 @@ +.o_field_properties { + width: 100%; +} + +// The web client will add a "o_field_invalid" class when the field is considered +// as not valid (at least one empty label). In that case we want to have in red +// only the empty label, and not all inputs / components. +.o_field_properties, +.o_field_properties.o_field_invalid, +.o_property_field_popover { + .o_input, + .o_dropdown_button, + .o_datepicker_button { + @include print-variable(o-input-border-color, $o-form-lightsecondary); + color: initial !important; + * { + color: initial !important; + } + } + + & { + .o_input:focus, + .dropdown:focus ~ .o_dropdown_button, + .dropdown:focus-within ~ .o_dropdown_button, + .o_input:focus ~ .o_datepicker_button, + .o_dropdown_button:focus { + @include print-variable(o-input-border-color, $o-brand-primary); + * { + @include print-variable(o-input-border-color, $o-brand-primary); + } + } + } + // only the empty property label should be affected by `o_field_invalid` + .o_field_property_empty_label { + color: map-get($theme-colors, 'danger'); + } +} + +.o_property_field { + .o_td_label, + .o_property_field_value { + padding: 0 45px 0 0; + } + .o_property_field_value { + height: fit-content; + } + + .o_field_property_dropdown input { + cursor: pointer; + } + + .o_input_dropdown { + min-width: 100%; + } + + .popover-body { + min-width: 500px; + } + .o_td_label { + min-width: 150px; + } + + .o_field_property_label { + max-width: 150px; + width: fit-content; + b { + word-break: break-word; + max-height: 100px; + overflow-y: hidden; + } + + &:hover .o_field_property_open_popover, + .o_field_property_open_popover:focus { + opacity: 1; + } + .o_field_property_open_popover { + opacity: 0; + cursor: pointer; + transition: 0.1s; + } + .o_field_property_open_popover:hover { + color: $primary; + } + } + + .o-dropdown { + .dropdown-menu { + max-height: 300px; + } + } + + .o_property_field_highlight .o_field_property_empty_label, + .o_property_field_highlight b, { + border: 1px solid $primary; + padding: 5px; + margin: -5px; + border-radius: 3px; + } +} + +.o_field_property_add { + // cancel button padding to align the + // text button with properties labels + margin-left: -1rem; +} + +.o_property_field_popover { + // put the popover behind datetime component + z-index: 1000 !important; + font-size: inherit; + box-shadow: 0 3rem 5rem rgba($black, .18) !important; + box-sizing: border-box; + .end-auto { + display: none; + } + + .o_input_dropdown { + width: 100%; + } +} + +.o_xxs_form_view .o_field_properties { + // mobile view + .o_property_field { + min-width: 100%; + flex-wrap: wrap; + padding: 0; + + .o_td_label, + .o_property_field_value { + min-width: 100%; + padding: 0; + &>* { + max-width: 100%; + } + .o_input_dropdown { + min-width: 0; + width: 100%; + } + } + } + .o_field_property_add, + .o_field_property_open_popover { + display: none; + } + .o_field_property_open_popover.disabled { + pointer-events: none; + } +} diff --git a/addons/web/static/src/views/fields/properties/properties_field.xml b/addons/web/static/src/views/fields/properties/properties_field.xml new file mode 100644 index 00000000000..86a51e62b56 --- /dev/null +++ b/addons/web/static/src/views/fields/properties/properties_field.xml @@ -0,0 +1,57 @@ + + + +
+
+
+
+ +
+
+ +
+
+
+ +
+
+
+
+
diff --git a/addons/web/static/src/views/fields/properties/property_definition.js b/addons/web/static/src/views/fields/properties/property_definition.js new file mode 100644 index 00000000000..d53e2b72a7d --- /dev/null +++ b/addons/web/static/src/views/fields/properties/property_definition.js @@ -0,0 +1,370 @@ +/** @odoo-module **/ + +import { _lt } from "@web/core/l10n/translation"; +import { PropertyValue } from "./property_value"; +import { DomainSelector } from "@web/core/domain_selector/domain_selector"; +import { Domain } from "@web/core/domain"; +import { Dropdown } from "@web/core/dropdown/dropdown"; +import { DropdownItem } from "@web/core/dropdown/dropdown_item"; +import { Many2XAutocomplete } from "@web/views/fields/relational_utils"; +import { useService, useOwnedDialogs } from "@web/core/utils/hooks"; +import { PropertyDefinitionSelection } from "./property_definition_selection"; +import { PropertyTags } from "./property_tags"; +import { sprintf } from "@web/core/utils/strings"; +import { SelectCreateDialog } from "@web/views/view_dialogs/select_create_dialog"; + +const { Component, useState, onWillUpdateProps, useEffect, useRef } = owl; + +export class PropertyDefinition extends Component { + setup() { + this.orm = useService("orm"); + + this.propertyDefinitionRef = useRef("propertyDefinition"); + this.addDialog = useOwnedDialogs(); + + const defaultDefinition = { + name: false, + string: "", + type: "char", + default: "", + }; + const propertyDefinition = { + ...defaultDefinition, + ...this.props.propertyDefinition, + }; + + this.state = useState({ + propertyDefinition: propertyDefinition, + typeLabel: this._typeLabel(propertyDefinition.type), + resModel: "", + resModelId: 0, + resModelDescription: "", + matchingRecordsCount: undefined, + }); + + this._syncStateWithProps(propertyDefinition); + + // update the state and fetch needed information + onWillUpdateProps((newProps) => + this._syncStateWithProps(newProps.value) + ); + + useEffect((event) => { + // focus the property label, when we open the property definition + if (this.labelFocused) { + // focus it only once + return; + } + this.labelFocused = true; + const labelInput = + this.propertyDefinitionRef.el.querySelectorAll("input")[0]; + if (labelInput) { + labelInput.focus(); + } + }); + } + + /* -------------------------------------------------------- + * Public methods / Getters + * -------------------------------------------------------- */ + + /** + * Return the list of property types with their labels. + * + * @returns {array} + */ + get availablePropertyTypes() { + return [ + ["char", _lt("Text")], + ["boolean", _lt("Checkbox")], + ["integer", _lt("Integer")], + ["float", _lt("Decimal")], + ["date", _lt("Date")], + ["datetime", _lt("Date & Time")], + ["selection", _lt("Selection")], + ["tags", _lt("Tags")], + ["many2one", _lt("Many2one")], + ["many2many", _lt("Many2many")], + ]; + } + + /** + * Return the list of tag values, that will be selected by the PropertyTags + * component (all existing tags because we are editing the definition). + * + * @returns {array} + */ + get propertyTagValues() { + return (this.state.propertyDefinition.tags || []).map((tag) => tag[0]); + } + + /* -------------------------------------------------------- + * Event handlers + * -------------------------------------------------------- */ + + /** + * We changed the string of the property. + * + * @param {event} event + */ + onPropertyLabelChange(event) { + const newString = event.target.value; + const propertyDefinition = { + ...this.state.propertyDefinition, + string: newString, + }; + this.props.onChange(propertyDefinition); + this.state.propertyDefinition = propertyDefinition; + } + + /** + * We changed the default value of the property. + * + * @param {object} newDefault + */ + onDefaultChange(newDefault) { + const propertyDefinition = { + ...this.state.propertyDefinition, + default: newDefault, + }; + this.props.onChange(propertyDefinition); + this.state.propertyDefinition = propertyDefinition; + } + + /** + * We selected a new property type. + * + * @param {string} newType + */ + onPropertyTypeChange(newType) { + const propertyDefinition = { + ...this.state.propertyDefinition, + type: newType, + default: false, + value: false, + }; + + this.props.onChange(propertyDefinition); + this.state.propertyDefinition = propertyDefinition; + this.state.typeLabel = this._typeLabel(newType); + } + + /** + * The model of the relational property (many2one / many2many) has been changed. + * + * @param {string} newModel + */ + async onModelChange(newModel) { + if (!newModel || !newModel.length || !newModel[0].id) { + // remove the model + const propertyDefinition = { + ...this.state.propertyDefinition, + comodel: false, + default: false, + domain: false, + value: false, + }; + this.props.onChange(propertyDefinition); + this.state.propertyDefinition = propertyDefinition; + this.state.resModel = ''; + this.state.resModelId = 0; + this.state.resModelDescription = ''; + await this._updateMatchingRecordsCount(); + return; + } + const newModelId = newModel[0].id; + + // if we change the model, we should reset the default value and the domain + const modelChanged = newModelId !== this.state.resModelId; + + this.state.resModelId = newModelId; + + const result = await this.orm.call( + 'ir.model', + 'read', + [[newModelId], ['model', 'name']], + ); + + if (!result || !result.length) { + return; + } + this.state.resModelDescription = result[0].name; + const propertyDefinition = { + ...this.state.propertyDefinition, + default: modelChanged + ? false + : this.state.propertyDefinition.default, + value: modelChanged ? false : this.state.propertyDefinition.value, + domain: modelChanged ? false : this.state.propertyDefinition.domain, + }; + this.props.onChange(propertyDefinition); + this.state.propertyDefinition = propertyDefinition; + await this._updateMatchingRecordsCount(); + } + + /** + * The domain of the relational property has been changed. + * + * @param {string} newDomain + */ + async onDomainChange(newDomain) { + const propertyDefinition = { + ...this.state.propertyDefinition, + domain: newDomain, + default: false, + }; + this.props.onChange(propertyDefinition); + this.state.propertyDefinition = propertyDefinition; + await this._updateMatchingRecordsCount(); + } + + /** + * Open the list view of the records matching the current domain. + */ + onButtonDomainClick() { + this.addDialog(SelectCreateDialog, { + title: this.env._t("Selected records"), + noCreate: true, + multiSelect: false, + resModel: this.state.propertyDefinition.comodel, + domain: new Domain( + this.state.propertyDefinition.domain || "[]" + ).toList(), + context: this.props.context || {}, + }); + } + + /** + * We renamed / created / removed a selection option. + * + * @param {array} newOptions + */ + onSelectionOptionChange(newOptions) { + const propertyDefinition = { + ...this.state.propertyDefinition, + selection: newOptions, + }; + this.props.onChange(propertyDefinition); + this.state.propertyDefinition = propertyDefinition; + } + + /** + * We renamed / created / removed tags. + * + * @param {array} newTags + */ + onTagsChange(newTags) { + const propertyDefinition = { + ...this.state.propertyDefinition, + tags: newTags, + }; + this.props.onChange(propertyDefinition); + this.state.propertyDefinition = propertyDefinition; + } + + /* -------------------------------------------------------- + * Private methods + * -------------------------------------------------------- */ + + /** + * The property value changed (e.g. we discard a form view editing). + * Re-update the state with the new props. + * + * @param {object} propertyDefinition + */ + async _syncStateWithProps(propertyDefinition) { + const currentModel = this.state.resModel; + const newModel = propertyDefinition.comodel; + + this.state.propertyDefinition = propertyDefinition; + this.state.typeLabel = this._typeLabel(propertyDefinition.type); + this.state.resModel = newModel; + + if (newModel && newModel !== currentModel) { + // retrieve the model id and the model description from it's name + // "res.partner" => (5, "Contact") + try { + const result = await this.orm.call( + "ir.model", + "search_read", + [ + [['model', '=', newModel]], + ['id', 'name'], + ], + ); + if (!result || !result.length) { + return; + } + this.state.resModelId = result[0].id; + this.state.resModelDescription = result[0].name; + } catch (_) { + // can not read the ir.model + this.state.resModelId = 0; + this.state.resModelDescription = sprintf( + _lt('You do not have access to the model "%s".'), + newModel + ); + } + + await this._updateMatchingRecordsCount(); + } else if (!newModel) { + this.state.resModelId = false; + this.state.resModelDescription = ""; + } + } + + /** + * Update the number of records that match the current domain. + */ + async _updateMatchingRecordsCount() { + if (this.state.resModelId && this.state.propertyDefinition.comodel) { + const domainList = new Domain( + this.state.propertyDefinition.domain || "[]" + ).toList(); + + const result = await this.orm.call( + this.state.propertyDefinition.comodel, + "search_count", + [domainList] + ); + + this.state.matchingRecordsCount = result; + } else { + this.state.matchingRecordsCount = undefined; + } + } + + /** + * Return the property label corresponding to the property type. + * + * @param {string} propertyType + * @returns {string} + */ + _typeLabel(propertyType) { + const allTypes = this.availablePropertyTypes; + return allTypes.find((type) => type[0] === propertyType)[1]; + } +} + +PropertyDefinition.template = "web.PropertyDefinition"; +PropertyDefinition.components = { + DomainSelector, + Dropdown, + DropdownItem, + PropertyValue, + Many2XAutocomplete, + PropertyDefinitionSelection, + PropertyTags, +}; +PropertyDefinition.props = { + readonly: { type: Boolean, optional: true }, + canChangeDefinition: { type: Boolean, optional: true }, + propertyDefinition: { optional: true }, + context: { type: Object }, + onChange: { type: Function, optional: true }, + onDelete: { type: Function, optional: true }, + onPropertyMove: { type: Function, optional: true }, + + // prop needed by the popover service + close: { type: Function, optional: true }, +}; diff --git a/addons/web/static/src/views/fields/properties/property_definition.scss b/addons/web/static/src/views/fields/properties/property_definition.scss new file mode 100644 index 00000000000..96e34dd3c1e --- /dev/null +++ b/addons/web/static/src/views/fields/properties/property_definition.scss @@ -0,0 +1,57 @@ +.o_field_property_definition_delete { + min-width: fit-content; +} + +.o_field_property_definition { + max-height: 70vh; + overflow-y: auto; + + table { + width: 500px; + } + + .o_td_label { + width: 120px; + } + + .o_field_property_definition_header { + max-width: 500px; + + input { + font-size: 1.2rem; + border-width: 1px; + } + } + + .o_field_property_definition_type { + img { + width: 20px; + height: 20px; + } + + .o_input_dropdown { + input { + background-repeat: no-repeat; + background-size: contain; + padding-left: 25px; + background-position-x: 0px; + background-position-y: 3px; + background-size: 20px; + } + } + } + + td { + padding-bottom: 15px; + } + .o_field_property_definition_value { + .form-check-input { + margin-left: 3px; + } + } + + .o_domain_debug_container { + width: 100%; + display: inline-block !important; + } +} diff --git a/addons/web/static/src/views/fields/properties/property_definition.xml b/addons/web/static/src/views/fields/properties/property_definition.xml new file mode 100644 index 00000000000..4939ea07472 --- /dev/null +++ b/addons/web/static/src/views/fields/properties/property_definition.xml @@ -0,0 +1,176 @@ + + + +
+
+
+ +
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Field Type + +
+ + +
+ + +
+ + +
+
+ + +
+ + +
+
+
+
+
+ Values + + +
+ Tags + + +
+ Model + + + + + +
+ Domain + + +
+ + +
+
+ + Default State + + + Default Value + + + +
+
+
+
diff --git a/addons/web/static/src/views/fields/properties/property_definition_selection.js b/addons/web/static/src/views/fields/properties/property_definition_selection.js new file mode 100644 index 00000000000..d3ac8cc6d9c --- /dev/null +++ b/addons/web/static/src/views/fields/properties/property_definition_selection.js @@ -0,0 +1,219 @@ +/** @odoo-module **/ + +import { useService } from "@web/core/utils/hooks"; +import { uuid } from "../../utils"; + +const { Component, useState, useRef, useEffect } = owl; + +export class PropertyDefinitionSelection extends Component { + setup() { + this.notification = useService("notification"); + + // when we create a new option, it's added in the state + // when we have finished to edit it (blur / enter) we propagate + // the new value in the props + this.state = useState({ newOption: null }); + + this.propertyDefinitionSelectionRef = useRef( + "propertyDefinitionSelection" + ); + this.addButtonRef = useRef("addButton"); + + useEffect(() => { + // automatically give the focus to the new option if it is empty + const inputs = + this.propertyDefinitionSelectionRef.el.querySelectorAll( + ".o_field_property_selection_option input" + ); + if (inputs && inputs.length && !inputs[inputs.length - 1].value) { + inputs[inputs.length - 1].focus(); + } + }); + } + + /* -------------------------------------------------------- + * Public methods / Getters + * -------------------------------------------------------- */ + + /** + * Return the current available options. + * + * Make a deep copy to not change original object to be able to restore + * the old props if we discard the editing of the forma view. + * + * @returns {array} + */ + get options() { + return JSON.parse(JSON.stringify(this.props.options || [])); + } + + /** + * Options visible by the UI, include the newly created option if needed. + * + * @returns {array} + */ + get optionsVisible() { + const options = this.options || []; + return this.state.newOption + ? [...options, this.state.newOption] + : options; + } + + /* -------------------------------------------------------- + * Event handlers + * -------------------------------------------------------- */ + + /** + * Add a new empty selection option. + */ + onOptionCreate() { + this.state.newOption = [uuid(), ""]; + } + + /** + * We changed an option label. + * + * @param {event} event + * @param {integer} optionIndex + */ + onOptionChange(event, optionIndex) { + const target = event.target; + const newLabel = target.value; + + if ( + this.options[optionIndex] && + this.options[optionIndex][1] === newLabel + ) { + // do not update the props if we are already up to date + // e.g. we pressed enter already and lost focus + return; + } + + const options = this.optionsVisible; + + if (!newLabel || !newLabel.length) { + // if the label is empty, remove the option + options.splice(optionIndex, 1); + } else { + options[optionIndex][1] = newLabel; + } + + const nonEmptyOptions = options.filter( + (option) => option[1] && option[1].length + ); + this.props.onOptionsChange(nonEmptyOptions); + + if ( + this.state.newOption && + this.state.newOption[1] && + this.state.newOption[1].length + ) { + // the new option has been propagated in the props + this.state.newOption = null; + } + } + + /** + * Loose focus on an option, should cancel the newly + * created option if we didn't write on it. + * + * @param {event} event + * @param {integer} optionIndex + */ + onOptionBlur(event, optionIndex) { + if (event.target.value && event.target.value.length) { + // losing the focus on an non-empty option should have no effect + return; + } + + if (event.relatedTarget === this.addButtonRef.el) { + // lost the focus because we click on the add button + // if the value is empty, just ignore and cancel the event + event.stopPropagation(); + event.preventDefault(); + } else if (optionIndex >= this.options.length) { + // we remove the focus from the new empty option, remove it + this.state.newOption = null; + } + } + + /** + * We pressed Enter on an option, add it if it's not + * empty and automatically create a new one. + * + * Navigate using the up / down arrows. + * + * @param {event} event + * @param {integer} optionIndex + */ + onOptionKeyDown(event, optionIndex) { + if (event.key === "Enter") { + const newLabel = event.target.value; + + if (!newLabel || !newLabel.length) { + // press enter on an empty option, just ignore it, nothing to save + event.stopPropagation(); + event.preventDefault(); + return; + } + + this.onOptionChange(event, optionIndex); + this.onOptionCreate(); + } else if (["ArrowUp", "ArrowDown"].includes(event.key)) { + event.stopPropagation(); + event.preventDefault(); + + if (event.key === "ArrowUp" && optionIndex > 0) { + const previousInput = event.target + .closest(".o_field_property_selection_option") + .previousElementSibling.querySelector("input"); + previousInput.focus(); + } else if ( + event.key === "ArrowDown" && + optionIndex < this.optionsVisible.length - 1 + ) { + const nextInput = event.target + .closest(".o_field_property_selection_option") + .nextElementSibling.querySelector("input"); + nextInput.focus(); + } + } + } + + /** + * Change the default selection option. + * + * @param {integer} optionIndex + */ + onOptionSetDefault(optionIndex) { + if (!this.props.canChangeDefinition) { + return; + } + const newValue = this.optionsVisible[optionIndex][0]; + this.props.onDefaultOptionChange( + newValue !== this.props.default ? newValue : false + ); + } + + /** + * Ask to remove the selection option. + * + * @param {integer} optionIndex + */ + onOptionDelete(optionIndex) { + const options = this.optionsVisible; + options.splice(optionIndex, 1); + this.props.onOptionsChange(options); + } +} + +PropertyDefinitionSelection.template = "web.PropertyDefinitionSelection"; + +PropertyDefinitionSelection.props = { + default: { type: String, optional: true }, + options: {}, + readonly: { type: Boolean, optional: true }, + canChangeDefinition: { type: Boolean, optional: true }, + onOptionsChange: { type: Function, optional: true }, // we add / remove / rename an option + onDefaultOptionChange: { type: Function, optional: true }, // we select a default value +}; diff --git a/addons/web/static/src/views/fields/properties/property_definition_selection.scss b/addons/web/static/src/views/fields/properties/property_definition_selection.scss new file mode 100644 index 00000000000..7bfdbea18f4 --- /dev/null +++ b/addons/web/static/src/views/fields/properties/property_definition_selection.scss @@ -0,0 +1,10 @@ +.o_field_property_selection { + input { + border: 0; + background: transparent; + } + .fa-star, + .fa-star-o { + color: gold; + } +} diff --git a/addons/web/static/src/views/fields/properties/property_definition_selection.xml b/addons/web/static/src/views/fields/properties/property_definition_selection.xml new file mode 100644 index 00000000000..1c52bddecab --- /dev/null +++ b/addons/web/static/src/views/fields/properties/property_definition_selection.xml @@ -0,0 +1,46 @@ + + + +
+
+
+ + + + +
+ +
+
+
+
diff --git a/addons/web/static/src/views/fields/properties/property_tags.js b/addons/web/static/src/views/fields/properties/property_tags.js new file mode 100644 index 00000000000..07364289065 --- /dev/null +++ b/addons/web/static/src/views/fields/properties/property_tags.js @@ -0,0 +1,294 @@ +/** @odoo-module **/ + +import { _lt } from "@web/core/l10n/translation"; +import { useService } from "@web/core/utils/hooks"; +import { TagsList } from "@web/views/fields/many2many_tags/tags_list"; +import { ColorList } from "@web/core/colorlist/colorlist"; +import { usePopover } from "@web/core/popover/popover_hook"; +import { AutoComplete } from "@web/core/autocomplete/autocomplete"; +import { sprintf } from "@web/core/utils/strings"; + +const { Component } = owl; + +class PropertyTagsColorListPopover extends Component {} +PropertyTagsColorListPopover.template = "web.PropertyTagsColorListPopover"; +PropertyTagsColorListPopover.components = { + ColorList, +}; + +export class PropertyTags extends Component { + setup() { + this.notification = useService("notification"); + this.popover = usePopover(); + } + + /* -------------------------------------------------------- + * Public methods / Getters + * -------------------------------------------------------- */ + + /** + * Return the list containing tags values and actions for the TagsList component. + * + * @returns {array} + */ + get tagListItems() { + if (!this.props.selectedTags || !this.props.selectedTags.length) { + return []; + } + + // Retrieve the tags label and color + // ['a', 'b'] => [['a', 'A', 5], ['b', 'B', 6]] + const value = this.props.tags.filter( + (tag) => this.props.selectedTags.indexOf(tag[0]) >= 0 + ); + + const canDeleteTag = !this.props.readonly && this.props.canChangeTags; + + return value.map((tag) => { + const [tagId, tagLabel, tagColorIndex] = tag; + return { + id: tagId, + text: tagLabel, + colorIndex: tagColorIndex || 0, + onClick: (event) => + this.onTagClick(event, tagId, tagColorIndex), + onDelete: canDeleteTag && (() => this.onTagDelete(tagId)), + }; + }); + } + + /** + * Return the current selected tags. + * Make a deep copy to not make change on the original object + * and to be able to discard change. + * + * @returns {array} + */ + get selectedTags() { + return JSON.parse(JSON.stringify(this.props.selectedTags || [])); + } + + /** + * Return the current tags that can be selected. + * Make a deep copy to not make change on the original object + * and to be able to discard change. + * + * @returns {array} + */ + get availableTags() { + return JSON.parse(JSON.stringify(this.props.tags || [])); + } + + /** + * Options available in the autocomplete component. + * + * @returns {array} + */ + get autocompleteSources() { + return [ + { + options: (request) => { + const tagsFiltered = this.props.tags.filter( + (tag) => + (!this.props.selectedTags || + this.props.selectedTags.indexOf(tag[0]) < 0) && + (!request || + !request.length || + tag[1] + .toLocaleLowerCase() + .indexOf(request.toLocaleLowerCase()) >= 0) + ); + if (!tagsFiltered || !tagsFiltered.length) { + // no result, ask the user if he want to create a new tag + if (!request || !request.length) { + return [ + { + value: null, + label: _lt("Start typing..."), + classList: "fst-italic", + }, + ]; + } else if (!this.props.canChangeTags) { + return [ + { + value: null, + label: _lt("No result"), + classList: "fst-italic", + }, + ]; + } + + return [ + { + value: { toCreate: true, value: request }, + label: sprintf(_lt('Create "%s"'), request), + classList: "o_field_property_dropdown_add", + }, + ]; + } + return tagsFiltered.map((tag) => { + return { + value: tag[0], + label: tag[1], + }; + }); + }, + }, + ]; + } + + /* -------------------------------------------------------- + * Event handlers + * -------------------------------------------------------- */ + + /** + * Add one value in the current tag list values. + * + * @param {string | object} tagValue + * Either + * - {toCreate: true, value: label}, to create a new value + * - value, to select an existing value + */ + onOptionSelected(tagValue) { + if (!tagValue) { + // clicked on "Start typing..." + return; + } + + if (tagValue.toCreate) { + this.onTagCreate(tagValue.value); + } else { + const selectedTags = this.selectedTags; + const newValue = [...selectedTags, tagValue]; + this.props.onValueChange(newValue); + } + } + + /** + * Ask to create a new tag that will be added in + * the definition and automatically selected. + * + * @param {string} newLabel + */ + onTagCreate(newLabel) { + if (!newLabel || !newLabel.length) { + return; + } + + const newValue = newLabel + ? newLabel.toLowerCase().replace(" ", "_") + : ""; + + const existingTag = this.props.tags.find((tag) => tag[0] === newValue); + if (existingTag) { + this.notification.add(_lt("This tag is already available"), { + type: "warning", + }); + return; + } + + // cycle trough colors + const tagColor = + this.props.tags && this.props.tags.length + ? (this.props.tags[this.props.tags.length - 1][2] + 1) % + ColorList.COLORS.length + : parseInt(Math.random() * ColorList.COLORS.length); + + const newTag = [newValue, newLabel, tagColor]; + const updatedTags = [...this.availableTags, newTag]; + // automatically select the newly created tag + const newValues = [...this.props.selectedTags, newTag[0]]; + this.props.onTagsChange(updatedTags, newValues); + } + + /** + * Click on the delete button on the tag pill. + * The behavior is defined by the prop "deleteAction". + * + * If we use the component for the tag configuration, clicking on "delete" + * will remove the tags from the available tags. If we use the component + * the the tag selection, it will unselect the tag. + * + * @param {string} deleteTag, ID of the tag to delete + */ + onTagDelete(deleteTag) { + if (this.props.deleteAction === "value") { + // remove the tag from the value (but keep it in the options list) + const selectedTags = this.selectedTags; + const newValue = selectedTags.filter((tag) => tag !== deleteTag); + this.props.onValueChange(newValue); + } else { + // remove the tag from the options + const availableTags = this.availableTags; + this.props.onTagsChange( + availableTags.filter((tag) => tag[0] !== deleteTag) + ); + } + } + + /** + * Click on a tag pill, open the color popover if we can change the tag definition. + * + * @param {event} event + * @param {string} tagId + * @param {integer} tagColor + */ + onTagClick(event, tagId, tagColor) { + if (!this.props.canChangeTags) { + event.currentTarget.blur(); + return; + } + this.popoverCloseFn = this.popover.add( + event.currentTarget, + this.constructor.components.Popover, + { + colors: [...Array(ColorList.COLORS.length).keys()], + tag: { id: tagId, colorIndex: tagColor }, + switchTagColor: this.onTagColorSwitch.bind(this), + }, + { + closeOnClickAway: true, + } + ); + } + + /** + * Ask to change the color of a tag. + * + * @param {integer} colorIndex + * @param {object} currentTag + */ + onTagColorSwitch(colorIndex, currentTag) { + const availableTags = this.availableTags; + availableTags.find((tag) => tag[0] === currentTag.id)[2] = colorIndex; + this.props.onTagsChange(availableTags); + + // close the color popover + this.popoverCloseFn(); + this.popoverCloseFn = null; + } +} + +PropertyTags.template = "web.PropertyTags"; +PropertyTags.components = { + AutoComplete, + TagsList, + ColorList, + Popover: PropertyTagsColorListPopover, +}; + +PropertyTags.props = { + selectedTags: {}, // Tags value visible in the tags list + tags: {}, // Tags definition visible in the dropdown + // Define the behavior of the delete button on the tags, either + // "value" or "tags". If "value", the delete button will unselect + // the value, if "tags" the value will be removed from the definition. + deleteAction: { type: String }, + readonly: { type: Boolean, optional: true }, + canChangeTags: { type: Boolean, optional: true }, + // Select a new value + onValueChange: { type: Function, optional: true }, + // Change the tags definition (can also receive a second + // argument to update the current selected value) + onTagsChange: { type: Function, optional: true }, +}; diff --git a/addons/web/static/src/views/fields/properties/property_tags.scss b/addons/web/static/src/views/fields/properties/property_tags.scss new file mode 100644 index 00000000000..72b9241f2b0 --- /dev/null +++ b/addons/web/static/src/views/fields/properties/property_tags.scss @@ -0,0 +1,25 @@ +.o_field_property_dropdown_add { + * { + color: $primary !important; + } +} +.o_field_property_tag_readonly { + // deactivate all click interactions with tags + // if we can't change their definition + pointer-events: none; +} + +.o_field_property_tag { + .o_tag { + margin: 1px 2px 1px 0; + .o_delete { + padding-left: 4px; + } + } + .o_delete { + margin-top: 2px; + } + .o_badge_text { + line-height: 1.1; + } +} diff --git a/addons/web/static/src/views/fields/properties/property_tags.xml b/addons/web/static/src/views/fields/properties/property_tags.xml new file mode 100644 index 00000000000..4fbec6cfbc1 --- /dev/null +++ b/addons/web/static/src/views/fields/properties/property_tags.xml @@ -0,0 +1,27 @@ + + + +
+ +
+ + +
+
+
+ +
+ +
+
+
diff --git a/addons/web/static/src/views/fields/properties/property_value.js b/addons/web/static/src/views/fields/properties/property_value.js new file mode 100644 index 00000000000..38e4d629062 --- /dev/null +++ b/addons/web/static/src/views/fields/properties/property_value.js @@ -0,0 +1,365 @@ +/** @odoo-module **/ + +import { Dropdown } from "@web/core/dropdown/dropdown"; +import { DropdownItem } from "@web/core/dropdown/dropdown_item"; +import { CheckBox } from "@web/core/checkbox/checkbox"; +import { DateTimePicker, DatePicker } from "@web/core/datepicker/datepicker"; +import { Domain } from "@web/core/domain"; +import { + Many2XAutocomplete, + useOpenMany2XRecord, +} from "@web/views/fields/relational_utils"; +import { useService } from "@web/core/utils/hooks"; +import { TagsList } from "@web/views/fields/many2many_tags/tags_list"; +import { m2oTupleFromData } from "@web/views/fields/many2one/many2one_field"; +import { PropertyTags } from "./property_tags"; +import { AutoComplete } from "@web/core/autocomplete/autocomplete"; +import { + formatFloat, + formatInteger, + formatMany2one, +} from "@web/views/fields/formatters"; +import { formatDate, formatDateTime } from "@web/core/l10n/dates"; + +const { Component } = owl; +const { DateTime } = luxon; + +// Formats to stringify the date / datetime in the JSON. +// It's important to have the year first, then the day, +// etc... in UTC, to be able to search on them. +const DEFAULT_SERVER_DATETIME_FORMAT = "yyyy-LL-dd HH:mm:ss"; +const DEFAULT_SERVER_DATE_FORMAT = "yyyy-LL-dd"; + +/** + * Represent one property value. + * Supports many types and instantiates the appropriate component for it. + * - Text + * - Integer + * - Boolean + * - Selection + * - Datetime & Date + * - Many2one + * - Many2many + * - Tags + * - ... + */ +export class PropertyValue extends Component { + setup() { + this.orm = useService("orm"); + this.action = useService("action"); + + this.openMany2X = useOpenMany2XRecord({ + resModel: this.props.model, + activeActions: { + canCreate: false, + canCreateEdit: false, + canWrite: true, + }, + isToMany: false, + onRecordSaved: async (record) => { + if (!record) { + return; + } + // maybe the record display name has changed + await record.load(); + const recordData = m2oTupleFromData(record.data); + await this.onValueChange([ + { id: recordData[0], name: recordData[1] }, + ]); + }, + fieldString: this.props.string, + }); + } + + /* -------------------------------------------------------- + * Public methods / Getters + * -------------------------------------------------------- */ + + /** + * Return the value of the current property, + * that will be used by the sub-components. + * + * @returns {object} + */ + get propertyValue() { + const value = this.props.value; + + if (this.props.type === "float") { + // force to show at least 1 digit, even for integers + return value; + } else if (this.props.type === "datetime") { + if (typeof value === "string") { + // convert the datetime from the UTC format to the current timezone + const datetimeValue = DateTime.fromFormat( + value + " +00:00", + DEFAULT_SERVER_DATETIME_FORMAT + " Z" + ); + return datetimeValue.invalid ? false : datetimeValue; + } + return value instanceof DateTime ? value : false; + } else if (this.props.type === "date") { + if (typeof value === "string") { + const datetimeValue = DateTime.fromFormat( + value + " +00:00", + DEFAULT_SERVER_DATE_FORMAT + " Z" + ); + return datetimeValue.invalid ? false : datetimeValue; + } + return value instanceof DateTime ? value : false; + } else if (this.props.type === "boolean") { + return !!value; + } else if (this.props.type === "selection") { + const options = this.props.selection || []; + const option = options.find((option) => option[0] === value); + return option && option.length === 2 && option[0] ? option[0] : ""; + } else if (this.props.type === "many2one") { + return !value || value.length !== 2 || !value[0] ? false : value; + } else if (this.props.type === "many2many") { + if (!value || !value.length) { + return []; + } + + // Convert to TagList component format + return value.map((many2manyValue) => { + return { + id: many2manyValue[0], + text: many2manyValue[1], + onClick: async () => + await this._openRecord( + this.props.comodel, + many2manyValue[0] + ), + onDelete: + !this.props.readonly && + (() => this.onMany2manyDelete(many2manyValue[0])), + colorIndex: 0, + }; + }); + } else if (this.props.type === "tags") { + return value || []; + } + + return value; + } + + /** + * Return the model domain (related to many2one and many2many properties). + * + * @returns {array} + */ + get propertyDomain() { + if (!this.props.domain || !this.props.domain.length) { + return []; + } + return new Domain(this.props.domain).toList(); + } + + /** + * Formatted value displayed in readonly mode. + * + * @returns {string} + */ + get displayValue() { + const value = this.propertyValue; + + if (this.props.type === "many2one" && value && value.length === 2) { + return formatMany2one(value); + } else if (!value) { + return false; + } else if (this.props.type === "datetime" && value) { + return formatDateTime(value); + } else if (this.props.type === "date" && value) { + return formatDate(value); + } else if (this.props.type === "selection") { + return this.props.selection.find( + (option) => option[0] === value + )[1]; + } else if (this.props.type === "float") { + return formatFloat(value); + } else if (this.props.type === "integer") { + return formatInteger(value); + } + return value.toString(); + } + + /* -------------------------------------------------------- + * Event handlers + * -------------------------------------------------------- */ + + /** + * Parse the value received by the sub-components and trigger an onChange event. + * + * @param {object} newValue + */ + async onValueChange(newValue) { + if (this.props.type === "datetime") { + if (typeof newValue === "string") { + newValue = DateTime.fromISO(newValue); + } + newValue = newValue + .toUTC() + .toFormat(DEFAULT_SERVER_DATETIME_FORMAT); + } else if (this.props.type === "date") { + if (typeof newValue === "string") { + newValue = DateTime.fromISO(newValue); + } + newValue = newValue.toFormat(DEFAULT_SERVER_DATE_FORMAT); + } else if (this.props.type === "integer") { + newValue = parseInt(newValue) || 0; + } else if (this.props.type === "float") { + newValue = parseFloat(newValue) || 0; + } else if (["many2one", "many2many"].includes(this.props.type)) { + // {id: 5, name: 'Demo'} => [5, 'Demo'] + newValue = + newValue && newValue.length && newValue[0].id + ? [newValue[0].id, newValue[0].name] + : false; + + if (newValue && newValue[0] && newValue[1] === undefined) { + // The "Search More" option in the Many2XAutocomplete component + // only return the record ID, and not the name. But we need to name + // in the component props to be able to display it. + // Make a RPC call to resolve the display name of the record. + newValue = await this._nameGet(newValue[0]); + } + + if (this.props.type === "many2many" && newValue) { + // add the record in the current many2many list + const currentValue = this.props.value || []; + const recordId = newValue[0]; + const exists = currentValue.find((rec) => rec[0] === recordId); + if (exists) { + return; + } + newValue = [...currentValue, newValue]; + } + } + + // trigger the onchange event to notify the parent component + this.props.onChange(newValue); + } + + /** + * Open the form view of the current record. + * + * @param {event} event + */ + async onMany2oneClick(event) { + if (this.props.readonly) { + event.stopPropagation(); + await this._openRecord(this.props.comodel, this.propertyValue[0]); + } + } + + /** + * Open the current many2one record form view in a modal. + */ + onExternalLinkClick() { + return this.openMany2X({ + resId: this.propertyValue[0], + forceModel: this.props.comodel, + context: this.context, + }); + } + + /** + * Removed a record from the many2many list. + * + * @param {integer} many2manyId + */ + onMany2manyDelete(many2manyId) { + // deep copy + const currentValue = JSON.parse(JSON.stringify(this.props.value || [])); + const newValue = currentValue.filter( + (value) => value[0] !== many2manyId + ); + this.props.onChange(newValue); + } + + /** + * Ask to create a record from a relational property. + * + * @param {string} name + * @param {object} params + */ + async onQuickCreate(name, params = {}) { + if (params.triggeredOnBlur) { + this.onValueChange(false); + return; + } + const result = await this.orm.call( + this.props.comodel, + "name_create", + [name], + { context: this.props.context } + ); + this.onValueChange([{ id: result[0], name: result[1] }]); + } + + /* -------------------------------------------------------- + * Private methods + * -------------------------------------------------------- */ + + /** + * Open the form view of the given record id / model. + * + * @param {string} recordModel + * @param {integer} recordId + */ + async _openRecord(recordModel, recordId) { + const action = await this.orm.call( + recordModel, + "get_formview_action", + [[recordId]], + { context: this.props.context } + ); + + this.action.doAction(action); + } + + /** + * Get the display name of the given record. + * Model is taken from the current selected model. + * + * @param {string} recordId + * @returns {array} [record id, record name] + */ + async _nameGet(recordId) { + const result = await this.orm.call( + this.props.comodel, + "name_get", + [[recordId]], + { context: this.props.context } + ); + return result[0]; + } +} + +PropertyValue.template = "web.PropertyValue"; + +PropertyValue.components = { + Dropdown, + DropdownItem, + CheckBox, + DateTimePicker, + DatePicker, + Many2XAutocomplete, + TagsList, + AutoComplete, + PropertyTags, +}; + +PropertyValue.props = { + type: { type: String, optional: true }, + comodel: { type: String, optional: true }, + domain: { type: String, optional: true }, + string: { type: String, optional: true }, + value: { optional: true }, + context: { type: Object }, + readonly: { type: Boolean, optional: true }, + canChangeDefinition: { type: Boolean, optional: true }, + selection: { type: Array, optional: true }, + tags: { type: Array, optional: true }, + onChange: { type: Function, optional: true }, + onTagsChange: { type: Function, optional: true }, +}; diff --git a/addons/web/static/src/views/fields/properties/property_value.scss b/addons/web/static/src/views/fields/properties/property_value.scss new file mode 100644 index 00000000000..32f6cb4ab22 --- /dev/null +++ b/addons/web/static/src/views/fields/properties/property_value.scss @@ -0,0 +1,34 @@ +.o_field_property_dropdown_menu { + .o-autocomplete--dropdown-menu { + max-height: 200px!important; + } +} + +// remove the down / up arrow for number input +input.o_field_property_input::-webkit-outer-spin-button, +input.o_field_property_input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} +input.o_field_property_input[type=number] { + -moz-appearance: textfield; +} + +.o_field_property_dropdown_empty { + height: 25px; +} + +.o_field_property_many2many_value { + .o_tag { + margin: 1px 2px 1px 0; + .o_delete { + padding-left: 4px; + } + } + .o_delete { + margin-top: 2px; + } + .o_badge_text { + line-height: 1.1; + } +} diff --git a/addons/web/static/src/views/fields/properties/property_value.xml b/addons/web/static/src/views/fields/properties/property_value.xml new file mode 100644 index 00000000000..8c819c90ee3 --- /dev/null +++ b/addons/web/static/src/views/fields/properties/property_value.xml @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ +
+ + +
+
+ + + + + + diff --git a/addons/web/static/src/views/fields/relational_utils.js b/addons/web/static/src/views/fields/relational_utils.js index a175ea72f5b..5a07252ff00 100644 --- a/addons/web/static/src/views/fields/relational_utils.js +++ b/addons/web/static/src/views/fields/relational_utils.js @@ -352,10 +352,11 @@ export function useOpenMany2XRecord({ const addDialog = useOwnedDialogs(); const orm = useService("orm"); - return async function openDialog({ resId = false, title, context }, immediate = false) { + return async function openDialog({ resId = false, forceModel = null, title, context }, immediate = false) { + const model = forceModel || resModel; let viewId; if (resId !== false) { - viewId = await orm.call(resModel, "get_formview_id", [[resId]], { + viewId = await orm.call(model, "get_formview_id", [[resId]], { context, }); } @@ -379,7 +380,7 @@ export function useOpenMany2XRecord({ context, mode, resId, - resModel, + resModel: model, viewId, onRecordSaved, isToMany, diff --git a/addons/web/static/src/views/utils.js b/addons/web/static/src/views/utils.js index a1295c68205..c95c041fc0a 100644 --- a/addons/web/static/src/views/utils.js +++ b/addons/web/static/src/views/utils.js @@ -252,3 +252,15 @@ export function stringToOrderBy(string) { export function toStringExpression(str) { return `\`${str.replaceAll("`", "\\`")}\``; } + +/** + * Generate a unique identifier. + * + * @returns {string} + */ +export function uuid() { + const array = new Uint8Array(16); + window.crypto.getRandomValues(array); + // Uint8Array to hex + return [...array].map(b => b.toString(16).padStart(2, '0')).join(''); +} diff --git a/addons/web/static/tests/views/fields/properties_field_tests.js b/addons/web/static/tests/views/fields/properties_field_tests.js new file mode 100644 index 00000000000..3484ae5a4be --- /dev/null +++ b/addons/web/static/tests/views/fields/properties_field_tests.js @@ -0,0 +1,1100 @@ +/** @odoo-module **/ + +import { + click, + editInput, + getFixture, + nextTick, + triggerEvent, +} from "@web/../tests/helpers/utils"; +import { makeView, setupViewRegistries } from "@web/../tests/views/helpers"; + +let serverData; +let target; + +async function closePopover(target) { + // Close the popover by clicking outside + document.activeElement.blur(); + await click(document, "html"); +} + +QUnit.module("Fields", (hooks) => { + hooks.beforeEach(() => { + target = getFixture(); + serverData = { + models: { + partner: { + fields: { + properties: { + string: "Properties", + type: "properties", + searchable: false, + definition_record: "company_id", + }, + company_id: { + string: "Company", + type: "many2one", + relation: "company", + }, + }, + records: [ + { + id: 1, + display_name: "first partner", + properties: [ + { + name: "property_1", + string: "My Char", + type: "char", + value: "char value", + }, + { + name: "property_2", + string: "My Selection", + type: "selection", + selection: [ + ["a", "A"], + ["b", "B"], + ["c", "C"], + ], + value: "b", + default: "c", + }, + ], + company_id: 37, + }, + { + id: 2, + display_name: "second partner", + properties: [ + { + name: "property_1", + string: "My Char", + type: "char", + value: "char value", + }, + { + name: "property_2", + string: "My Selection", + type: "selection", + selection: [ + ["a", "A"], + ["b", "B"], + ["c", "C"], + ], + value: "c", + default: "c", + }, + { + name: "property_3", + string: "My Char 3", + type: "char", + value: "char value 3", + }, + { + name: "property_4", + string: "My Char 4", + type: "char", + value: "char value 4", + }, + ], + company_id: 37, + }, + ], + }, + company: { + fields: { + name: { + string: "Name", + type: "char", + }, + }, + records: [ + { + id: 37, + display_name: "Company 1", + }, + ], + }, + }, + }; + + setupViewRegistries(); + }); + + QUnit.module("PropertiesField"); + + /** + * If the current user can not write on the parent, he should not + * be able to change the properties definition (but he should be able to + * change the properties value). + */ + QUnit.test("properties: no access to parent", async function (assert) { + async function mockRPC(route, { method, model, kwargs }) { + if (method === "check_access_rights") { + return false; + } + } + + await makeView({ + type: "form", + resModel: "partner", + resId: 1, + serverData, + arch: ` +
+ + + + + + +
`, + mockRPC, + }); + + await click(target, ".o_form_button_edit"); + + const field = target.querySelector(".o_field_properties"); + assert.ok(field, "The field must be in the view"); + + const addButton = target.querySelector(".o_field_property_add button"); + assert.notOk(addButton, "The add button must not be in the view"); + + const editButton = field.querySelector( + ".o_field_property_open_popover" + ); + assert.notOk( + editButton, + "The edit definition button must not be in the view" + ); + + const property = field.querySelector(".o_property_field_value input"); + assert.strictEqual(property.value, "char value"); + }); + + /** + * If the current user can write on the parent, he should + * be able to change the properties definition. + */ + QUnit.test("properties: access to parent", async function (assert) { + async function mockRPC(route, { method, model, kwargs }) { + if (method === "check_access_rights") { + return true; + } + } + + await makeView({ + type: "form", + resModel: "partner", + resId: 1, + serverData, + arch: ` +
+ + + + + + +
`, + mockRPC, + }); + + await click(target, ".o_form_button_edit"); + + const field = target.querySelector(".o_field_properties"); + assert.ok(field, "The field must be in the view"); + + const addButton = target.querySelector(".o_field_property_add button"); + assert.ok(addButton, "The add button must be in the view"); + + const editButton = field.querySelectorAll( + ".o_field_property_open_popover" + ); + assert.ok(editButton, "The edit definition button must be in the view"); + + const property = field.querySelector(".o_property_field_value input"); + assert.strictEqual(property.value, "char value"); + + // Open the definition popover + await click( + target, + ".o_property_field:first-child .o_field_property_open_popover" + ); + + const popover = target.querySelector(".o_property_field_popover"); + assert.ok(popover, "Should have opened the definition popover"); + + const label = popover.querySelector( + ".o_field_property_definition_header input" + ); + assert.strictEqual(label.value, "My Char"); + + const type = popover.querySelector( + ".o_field_property_definition_type input" + ); + assert.strictEqual(type.value, "Text"); + + // Change the property type to "Date & Time" + await editInput( + target, + ".o_field_property_definition_header input", + "My Datetime" + ); + await click(target, ".o_field_property_definition_type button"); + await click( + target, + ".o_field_property_definition_type .dropdown-menu .dropdown-item:nth-child(6)" + ); + assert.strictEqual( + type.value, + "Date & Time", + "Should have changed the property type" + ); + + // Choosing a date in the date picker should not close the definition popover + await click( + target, + ".o_field_property_definition_value .o_datepicker_input" + ); + await click( + document, + ".datepicker-days tr:first-child .day:nth-child(3)" + ); + assert.ok( + document.querySelector(".picker-switch .fa-check"), + "Should not close the definition popover after selecting a date" + ); + await click(document, ".picker-switch .fa-check"); + assert.ok( + target.querySelector(".o_property_field_popover"), + "Should not close the definition popover after selecting a date" + ); + + await closePopover(target); + + // Check that the type change have been propagated + const datetimeLabel = field.querySelector(".o_field_property_label b"); + assert.strictEqual( + datetimeLabel.innerText, + "My Datetime", + "Should have updated the property label" + ); + const datetimeComponent = field.querySelector( + ".o_property_field_value .o_datepicker" + ); + assert.ok(datetimeComponent, "Should have changed the property type"); + + // Check that the value is reset (because the type changed) + const inputValue = document.querySelector( + ".o_property_field_value input" + ); + assert.notOk(inputValue.value); + + // Discard the form view and check that the properties take its old values + await click(target, ".o_form_button_cancel"); + const propertyValue = document.querySelector( + ".o_property_field:first-child .o_property_field_value" + ); + assert.strictEqual( + propertyValue.innerText, + "char value", + "Discarding the form view should reset the old values" + ); + }); + + /** + * Test the creation of a new property. + */ + QUnit.test("properties: add a new property", async function (assert) { + async function mockRPC(route, { method, model, kwargs }) { + if (method === "check_access_rights") { + return true; + } + } + + await makeView({ + type: "form", + resModel: "partner", + resId: 1, + serverData, + arch: ` +
+ + + + + + +
`, + mockRPC, + }); + + await click(target, ".o_form_button_edit"); + + const field = target.querySelector(".o_field_properties"); + assert.ok(field, "The field must be in the view"); + + const addButton = target.querySelector(".o_field_property_add button"); + assert.ok(addButton, "The add button must be in the view"); + + // Create a new property + await click(target, ".o_field_property_add button"); + + await nextTick(); + + const popover = target.querySelector(".o_property_field_popover"); + assert.ok(popover, "Should have opened the definition popover"); + + const label = popover.querySelector( + ".o_field_property_definition_header input" + ); + assert.strictEqual( + label.value, + "Property 3", + "Should have added a default label" + ); + + const type = popover.querySelector( + ".o_field_property_definition_type input" + ); + assert.strictEqual(type.value, "Text", "Default type must be text"); + + await closePopover(target); + + const properties = field.querySelectorAll(".o_property_field"); + assert.strictEqual(properties.length, 3); + + const newProperty = properties[2]; + const newPropertyLabel = newProperty.querySelector( + ".o_field_property_label" + ); + assert.strictEqual(newPropertyLabel.innerText, "Property 3"); + }); + + /** + * Test the selection property. + */ + QUnit.test("properties: selection", async function (assert) { + async function mockRPC(route, { method, model, kwargs }) { + if (method === "check_access_rights") { + return true; + } + } + + await makeView({ + type: "form", + resModel: "partner", + resId: 1, + serverData, + arch: ` +
+ + + + + + +
`, + mockRPC, + }); + + await click(target, ".o_form_button_edit"); + + const field = target.querySelector(".o_field_properties"); + assert.ok(field, "The field must be in the view"); + + const selectionValue = target.querySelector( + ".o_property_field:nth-child(2) select" + ); + assert.ok(selectionValue); + assert.strictEqual(selectionValue.value, "b"); + + // Edit the selection property + await click( + target, + ".o_property_field:nth-child(2) .o_field_property_open_popover" + ); + + const popover = target.querySelector(".o_property_field_popover"); + assert.ok(popover, "Should have opened the definition popover"); + + const selectionComponent = popover.querySelector( + ".o_field_property_selection" + ); + assert.ok( + selectionComponent, + "Must instantiate the selection component" + ); + + // Check the default option + const option1 = popover.querySelector( + ".o_field_property_selection_option:nth-child(1)" + ); + assert.notOk( + option1.querySelector(".fa-star"), + "Default option must be the third one" + ); + const option2 = popover.querySelector( + ".o_field_property_selection_option:nth-child(2)" + ); + assert.notOk( + option2.querySelector(".fa-star"), + "Default option must be the third one" + ); + const option3 = popover.querySelector( + ".o_field_property_selection_option:nth-child(3)" + ); + assert.ok(option3); + assert.ok( + option3.querySelector(".fa-star"), + "Default option must be the third one" + ); + + const type = popover.querySelector( + ".o_field_property_definition_type input" + ); + assert.strictEqual(type.value, "Selection"); + + // Create a new selection option + await click(target, ".o_field_property_selection .fa-plus"); + let options = popover.querySelectorAll( + ".o_field_property_selection_option" + ); + assert.strictEqual( + options.length, + 4, + "Should have added the new option" + ); + assert.strictEqual( + document.activeElement, + options[3].querySelector("input"), + "Should focus the new option" + ); + + await editInput( + target, + ".o_field_property_selection_option:nth-child(4) input", + "New option" + ); + + // Press enter to add a second new option + await triggerEvent( + target, + ".o_field_property_selection_option:nth-child(4) input", + "keydown", + { key: "Enter" } + ); + await nextTick(); + + options = popover.querySelectorAll( + ".o_field_property_selection_option" + ); + assert.strictEqual( + options.length, + 5, + "Should have added the new option on Enter" + ); + assert.strictEqual( + document.activeElement, + options[4].querySelector("input"), + "Should focus the new option" + ); + + // Up arrow should give the focus to the previous option + // because the new option is empty and lost focus, it should be removed + await triggerEvent( + target, + ".o_field_property_selection_option:nth-child(5) input", + "keydown", + { key: "ArrowUp" } + ); + + await nextTick(); + + assert.strictEqual( + document.activeElement, + options[3].querySelector("input"), + "Should focus the previous option" + ); + options = popover.querySelectorAll( + ".o_field_property_selection_option" + ); + assert.strictEqual( + options.length, + 4, + "Should have remove the option because it is empty and lost focus" + ); + + await nextTick(); + + // Up again, should focus the previous option + await triggerEvent( + target, + ".o_field_property_selection_option:nth-child(4) input", + "keydown", + { key: "ArrowUp" } + ); + await nextTick(); + + assert.strictEqual( + document.activeElement, + options[2].querySelector("input") + ); + options = popover.querySelectorAll( + ".o_field_property_selection_option" + ); + assert.strictEqual(options.length, 4, "Should not remove any options"); + + // Remove the second option + await click( + target, + ".o_field_property_selection_option:nth-child(2) .fa-times" + ); + options = popover.querySelectorAll( + ".o_field_property_selection_option" + ); + assert.strictEqual( + options.length, + 3, + "Should have removed the second option" + ); + const optionValues = [...options].map( + (option) => option.querySelector("input").value + ); + assert.deepEqual( + optionValues, + ["A", "C", "New option"], + "Should have removed the second option" + ); + }); + + /** + * Test the properties re-arrangement + */ + QUnit.test("properties: move properties", async function (assert) { + async function mockRPC(route, { method, model, kwargs }) { + if (method === "check_access_rights") { + return true; + } + } + + await makeView({ + type: "form", + resModel: "partner", + resId: 2, + serverData, + arch: ` +
+ + + + + + +
`, + mockRPC, + }); + + // Return the properties labels + const getLabels = () => { + const labels = target.querySelectorAll( + ".o_field_properties .o_field_property_label" + ); + return [...labels].map((label) => label.innerText); + }; + + await click(target, ".o_form_button_edit"); + + const field = target.querySelector(".o_field_properties"); + assert.ok(field, "The field must be in the view"); + + // Edit the selection property + await click( + target, + ".o_property_field:nth-child(2) .o_field_property_open_popover" + ); + + const popover = target.querySelector(".o_property_field_popover"); + assert.ok(popover, "Should have opened the definition popover"); + + // Move the property up + await click(popover, ".fa-chevron-up"); + assert.deepEqual( + getLabels(), + ["My Selection", "My Char", "My Char 3", "My Char 4"], + "Should have moved up the property" + ); + const movedProperty = target.querySelector( + ".o_property_field:nth-child(1) .o_property_field_highlight" + ); + assert.ok(movedProperty, "Should highlight the moved property"); + + // Move the property up again, should have no effect + await click(popover, ".fa-chevron-up"); + assert.deepEqual(getLabels(), [ + "My Selection", + "My Char", + "My Char 3", + "My Char 4", + ]); + + // Move the property down + await click(popover, ".fa-chevron-down"); + assert.deepEqual(getLabels(), [ + "My Char", + "My Selection", + "My Char 3", + "My Char 4", + ]); + + // Move the property at the bottom + await click(popover, ".fa-chevron-down"); + await click(popover, ".fa-chevron-down"); + assert.deepEqual(getLabels(), [ + "My Char", + "My Char 3", + "My Char 4", + "My Selection", + ]); + + await closePopover(target); + + const highlightProperty = target.querySelector( + ".o_property_field:nth-child(2) .o_property_field_highlight" + ); + assert.notOk(highlightProperty, "Should have removed the highlight"); + }); + + /** + * Test the properties tags + */ + QUnit.test("properties: tags", async function (assert) { + async function mockRPC(route, { method, model, kwargs }) { + if (method === "check_access_rights") { + return true; + } + } + + await makeView({ + type: "form", + resModel: "partner", + resId: 2, + serverData, + arch: ` +
+ + + + + + +
`, + mockRPC, + }); + + const createNewTag = async (selector, text) => { + await click(target, selector); + await editInput(target, selector, text); + for (let i = 0; i < 50; ++i) { + // Wait until the dropdown appears + await nextTick(); + } + await triggerEvent(target, selector, "keydown", { key: "Enter" }); + await nextTick(); + }; + + const getVisibleTags = (target, selector) => { + const tags = target.querySelectorAll(selector); + return [...tags].map((tag) => tag.innerText); + }; + + await click(target, ".o_form_button_edit"); + await click( + target, + ".o_property_field:nth-child(2) .o_field_property_open_popover" + ); + let popover = target.querySelector(".o_property_field_popover"); + // Select the tags type + await click(popover, ".o_field_property_definition_type input"); + await nextTick(); + await click( + popover, + ".o_field_property_definition_type .dropdown-item:nth-child(8)" + ); + + // Create 3 tags + const tagsInputSelector = + ".o_property_field_popover .o_field_property_dropdown_menu input"; + await createNewTag(tagsInputSelector, "A"); + await createNewTag(tagsInputSelector, "B"); + await createNewTag(tagsInputSelector, "C"); + assert.deepEqual(getVisibleTags(popover, ".o_tag"), ["A", "B", "C"]); + + await closePopover(target); + + // Edit the tags value + const tagsComponent = target.querySelector( + ".o_property_field_value .o_input_dropdown" + ); + await click(target, ".o_property_field_value .o_input_dropdown input"); + + // Check that he newly created tags are available + assert.deepEqual( + getVisibleTags(tagsComponent, ".dropdown-item"), + ["A", "B", "C"], + "Should be able to selected the created tags" + ); + + // Select one tag in the list + await click( + target, + ".o_property_field_value .ui-menu-item:nth-child(2)" + ); + assert.deepEqual( + getVisibleTags(target, ".o_property_field_value .o_tag"), + ["B"], + "Should have selected the tag B" + ); + + // Re-open the tag dropdown and verify that the selected tag is not in the list + // (because it's already selected) + await click(target, ".o_property_field_value .o_input_dropdown input"); + assert.deepEqual( + getVisibleTags(tagsComponent, ".dropdown-item"), + ["A", "C"], + "The tag B is already selected and should not be visible in the dropdown" + ); + + // Create a new tag from the property value component + await createNewTag( + ".o_property_field_value .o_field_property_dropdown_menu input", + "D" + ); + assert.deepEqual( + getVisibleTags(target, ".o_property_field_value .o_tag"), + ["B", "D"], + "Should have created and selected the tag D" + ); + + // Re-open the popover and check that the new tag has been added in the definition + await click( + target, + ".o_property_field:nth-child(2) .o_field_property_open_popover" + ); + await nextTick(); + popover = target.querySelector(".o_property_field_popover"); + assert.deepEqual(getVisibleTags(popover, ".o_tag"), [ + "A", + "B", + "C", + "D", + ]); + + // Change the tag color + await click(popover, ".o_tag:nth-child(2)"); + await click(target, ".o_tag_popover .o_colorlist_item_color_11"); + let secondTag = popover.querySelector(".o_tag:nth-child(2)"); + assert.ok( + secondTag.classList.contains("o_tag_color_11"), + "Should have changed the tag color" + ); + + // Check that the new B color has been propagated in the form view + await closePopover(target); + secondTag = target.querySelector( + ".o_property_field_value .o_tag:first-child" + ); + assert.ok( + secondTag.classList.contains("o_tag_color_11"), + "Should have changed the tag color" + ); + + // Open the popover and remove B from the definition + await click( + target, + ".o_property_field:nth-child(2) .o_field_property_open_popover" + ); + await click( + target, + ".o_property_field_popover .o_tag:nth-child(2) .o_delete" + ); + await closePopover(); + const tags = target.querySelectorAll(".o_property_field_value .o_tag"); + assert.strictEqual( + tags.length, + 1, + "Should have unselected the removed tag B" + ); + }); + + /** + * Test the properties many2one + */ + QUnit.test("properties: many2one", async function (assert) { + async function mockRPC(route, { method, model, args, kwargs }) { + if (method === "check_access_rights") { + return true; + } else if (method === "name_search" && model === "ir.model") { + return [[1337, "Partner"], [1338, "User"]]; + } else if (method === "read" && model === "ir.model") { + return [{"id": 1338, "model": "res.users", "name": "User"}]; + } else if (method === "name_search" && model === "res.users") { + return [ + [1, "Alice"], + [2, "Bob"], + [3, "Eve"], + ]; + } else if (method === "name_create" && model === "res.users") { + // Add a prefix to check that "name_create" + // has been called with the right parameters + return [1234, "Created:" + args[0]]; + } else if (method === "fields_get" && model === "res.users") { + return { + name: { searchable: true, string: "Name", type: "char" }, + login: { searchable: true, string: "Name", type: "char" }, + }; + } else if (method === "search_count" && model === "res.users") { + return 5; + } + } + + await makeView({ + type: "form", + resModel: "partner", + resId: 2, + serverData, + arch: ` +
+ + + + + + +
`, + mockRPC, + }); + + await click(target, ".o_form_button_edit"); + await click( + target, + ".o_property_field:nth-child(2) .o_field_property_open_popover" + ); + let popover = target.querySelector(".o_property_field_popover"); + // Select the many2one type + await click(popover, ".o_field_property_definition_type input"); + await nextTick(); + await click( + popover, + ".o_field_property_definition_type .dropdown-item:nth-child(9)" + ); + + // Choose the "User" model + await click(popover, ".o_field_property_definition_model input"); + let models = target.querySelectorAll( + ".o_field_property_definition_model .ui-menu-item" + ); + models = [...models].map((model) => model.innerText); + assert.deepEqual(models, ["Partner", "User"]); + await click( + popover, + ".o_field_property_definition_model .ui-menu-item:nth-child(2)" + ); + + const selectedModel = target.querySelector( + ".o_field_property_definition_model input" + ); + assert.strictEqual( + selectedModel.value, + "User", + "Should have selected the User model" + ); + + // Choose a many2one value + await click(popover, ".o_field_property_definition_value input"); + await click( + popover, + ".o_field_property_definition_value .ui-menu-item:nth-child(3)" + ); + let selectedUser = target.querySelector( + ".o_field_property_definition_value input" + ); + assert.strictEqual( + selectedUser.value, + "Eve", + "Should have selected the third user" + ); + + await closePopover(target); + + // Quick create a user + await click( + target, + ".o_property_field:nth-child(2) .o_property_field_value input" + ); + await editInput( + target, + ".o_property_field:nth-child(2) input", + "New User" + ); + for (let i = 0; i < 50; ++i) await nextTick(); // wait until the dropdown appears + await click( + target, + ".o_property_field:nth-child(2) .o_m2o_dropdown_option_create" + ); + selectedUser = target.querySelector( + ".o_property_field:nth-child(2) .o_property_field_value input" + ); + assert.strictEqual( + selectedUser.value, + "Created:New User", + "Should have created a new user" + ); + }); + + /** + * Test the properties many2many + */ + QUnit.test("properties: many2many", async function (assert) { + async function mockRPC(route, { method, model, args, kwargs }) { + if (method === "check_access_rights") { + return true; + } else if (method === "name_search" && model === "ir.model") { + return [[1337, "Partner"], [1338, "User"]]; + } else if (method === "read" && model === "ir.model") { + return [{"id": 1338, "model": "res.users", "name": "User"}]; + } else if (method === "name_search" && model === "res.users") { + return [ + [1, "Alice"], + [2, "Bob"], + [3, "Eve"], + ]; + } else if (method === "name_create" && model === "res.users") { + // Add a prefix to check that "name_create" + // has been called with the right parameters + return [1234, "Created:" + args[0]]; + } else if (method === "search_count" && model === "res.users") { + return 5; + } + } + + await makeView({ + type: "form", + resModel: "partner", + resId: 2, + serverData, + arch: ` +
+ + + + + + +
`, + mockRPC, + }); + + const getSelectedUsers = () => { + const selectedUsers = target.querySelectorAll( + ".o_property_field_value .o_tag_badge_text" + ); + return [...selectedUsers].map((badge) => badge.innerText); + }; + + await click(target, ".o_form_button_edit"); + await click( + target, + ".o_property_field:nth-child(2) .o_field_property_open_popover" + ); + let popover = target.querySelector(".o_property_field_popover"); + // Select the many2many type + await click(popover, ".o_field_property_definition_type input"); + await nextTick(); + await click( + popover, + ".o_field_property_definition_type .dropdown-item:nth-child(10)" + ); + + // Choose the "User" model + await click(popover, ".o_field_property_definition_model input"); + let models = target.querySelectorAll( + ".o_field_property_definition_model .ui-menu-item" + ); + models = [...models].map((model) => model.innerText); + assert.deepEqual(models, ["Partner", "User"]); + await click( + popover, + ".o_field_property_definition_model .ui-menu-item:nth-child(2)" + ); + + const selectedModel = target.querySelector( + ".o_field_property_definition_model input" + ); + assert.strictEqual( + selectedModel.value, + "User", + "Should have selected the User model" + ); + + await closePopover(target); + + // Add Eve in the list + await click(target, ".o_property_field:nth-child(2) input"); + await click( + target, + ".o_property_field:nth-child(2) .ui-menu-item:nth-child(3)" + ); + assert.deepEqual( + getSelectedUsers(), + ["Eve"], + "Should have selected the third user" + ); + + // Add Bob in the list + await click(target, ".o_property_field:nth-child(2) input"); + await click( + target, + ".o_property_field:nth-child(2) .ui-menu-item:nth-child(2)" + ); + assert.deepEqual( + getSelectedUsers(), + ["Eve", "Bob"], + "Should have selected the second user" + ); + + // Quick create a user + await click( + target, + ".o_property_field:nth-child(2) .o_property_field_value input" + ); + await editInput( + target, + ".o_property_field:nth-child(2) input", + "New User" + ); + for (let i = 0; i < 50; ++i) await nextTick(); // wait until the dropdown appears + await click( + target, + ".o_property_field:nth-child(2) .o_m2o_dropdown_option_create" + ); + assert.deepEqual( + getSelectedUsers(), + ["Eve", "Bob", "Created:New User"], + "Should have created a new user" + ); + + // Remove Bob from the list + await click( + target, + ".o_property_field:nth-child(2) .o_tag:nth-child(2) .o_delete" + ); + assert.deepEqual( + getSelectedUsers(), + ["Eve", "Created:New User"], + "Should have removed Bob from the list" + ); + }); +});