From 29c7b23e22317ae06ec4052dd3edd9e2478e14ab Mon Sep 17 00:00:00 2001 From: std-odoo Date: Wed, 31 May 2023 08:55:00 +0200 Subject: [PATCH] [IMP] base, web: add the "separator" properties type Purpose ======= Add the "separator" properties type, to be able to group properties. Specification ============= The separator creates a group until the next separator, we can fold and unfold the properties in a group. Technical ========= The separator is only stored on the definition record, so it does not take more space than needed. The "fold" information is stored in the local storage, and is therefore per user, but shared for all records of the same parent. We use a properties type for it, to be able to use the exact same code as other properties (so we can easily move the separator, etc). So the order of the properties in the definition, and the position of the separators will create the groups. Task-3188915 Part-of: odoo/odoo#113974 --- addons/web/static/src/search/search_model.js | 3 + .../fields/properties/icons/separator.png | Bin 0 -> 252 bytes .../fields/properties/properties_field.js | 284 ++++++++++-- .../fields/properties/properties_field.scss | 62 +-- .../fields/properties/properties_field.xml | 44 +- .../fields/properties/property_definition.js | 1 + .../fields/properties/property_definition.xml | 5 +- .../views/fields/properties_field_tests.js | 426 +++++++++++++++++- .../test_new_api/tests/test_properties.py | 23 + odoo/fields.py | 12 +- 10 files changed, 771 insertions(+), 89 deletions(-) create mode 100644 addons/web/static/src/views/fields/properties/icons/separator.png diff --git a/addons/web/static/src/search/search_model.js b/addons/web/static/src/search/search_model.js index 4491ce44e03..aac2e7f945a 100644 --- a/addons/web/static/src/search/search_model.js +++ b/addons/web/static/src/search/search_model.js @@ -1014,6 +1014,9 @@ export class SearchModel extends EventBus { for (const { definitionRecordId, definitionRecordName, definitions } of result) { for (const definition of definitions) { + if (definition.type === "separator") { + continue; + } const existingSearchItem = existingFieldProperties[definition.name]; if (existingSearchItem) { // already in the list, can happen if we unfold the properties field diff --git a/addons/web/static/src/views/fields/properties/icons/separator.png b/addons/web/static/src/views/fields/properties/icons/separator.png new file mode 100644 index 0000000000000000000000000000000000000000..ed77b624cbe490363231abc9475151452146e351 GIT binary patch literal 252 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTC#^NA%Cx&(BWL^R}+dN$yLo%G- zUNPiqc3@x)+}k_B%_A}S@yW|KmT=tQxaSt_e5$=IcHdw5yEBh&)BKak{r{gD`;YR2 zA|8w;6SP~HD(5k+5n$A8z{ZG+Z%mvsZ_QJu1J!l4=YGs*y1V$kevSR&?WI3|J^RHM w@JIB~F>~fuzq#+~Oo*S3+Z { await this._checkDefinitionAccess(); }); - useEffect(() => { - this._movePopoverIfNeeded(); + useEffect( + () => { + if (this.openPropertyDefinition) { + const propertyName = this.openPropertyDefinition; + const labels = this.propertiesRef.el.querySelectorAll( + `.o_property_field[property-name="${propertyName}"] .o_field_property_open_popover` + ); + this.openPropertyDefinition = null; + const lastLabel = labels[labels.length - 1]; + this._openPropertyDefinition(lastLabel, propertyName, true); + } + }, + () => [this.openPropertyDefinition] + ); - 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]; - this._openPropertyDefinition(lastLabel, lastPropertyName, true); - } - }); + useEffect(() => this._movePopoverIfNeeded()); } /* -------------------------------------------------------- * Public methods / Getters * -------------------------------------------------------- */ + /** + * Return the number of columns we have to render + * (The properties can be split in many column, + * to follow the layout of the form view) + * + * @returns {object} + */ + get renderedColumnsCount() { + return this.env.isSmall ? 1 : this.props.columns; + } + /** * Return the current properties value. * @@ -97,17 +112,59 @@ export class PropertiesField extends Component { /** * Return the current properties value splitted in multiple groups/columns. + * Each properties are splitted in groups, thanks to the separators, and + * groups are splitted in columns (the columns property is the number of groups + * we have on a row). + * + * The groups are created with the separators (special type of property) so + * the order mater in the group creation. * * @returns {Array} */ get groupedPropertiesList() { - const columns = this.env.isSmall ? 1 : this.props.columns; - // If no properties, assure that the "Add Property" button is shown. - const res = [...Array(columns)].map((col) => []); - this.propertiesList.forEach((val, index) => { - res[index % columns].push(val); + const propertiesList = this.propertiesList; + // default invisible group + const groupedProperties = + propertiesList[0]?.type !== "separator" + ? [{ title: null, name: null, elements: [], invisibleLabel: true }] + : []; + + propertiesList.forEach((property) => { + if (property.type === "separator") { + groupedProperties.push({ + title: property.string, + name: property.name, + elements: [], + }); + } else { + groupedProperties.at(-1).elements.push(property); + } }); - return res; + + if (groupedProperties.length === 1) { + // only one group, split this group in the columns to take the entire width + const invisibleLabel = propertiesList[0]?.type !== "separator"; + groupedProperties[0].elements = []; + groupedProperties[0].invisibleLabel = invisibleLabel; + for (let col = 1; col < this.renderedColumnsCount; ++col) { + groupedProperties.push({ + title: null, + name: groupedProperties[0].name, + columnSeparator: true, + elements: [], + invisibleLabel, + }); + } + const properties = propertiesList.filter((property) => property.type !== "separator"); + properties.forEach((property, index) => { + const columnIndex = Math.floor( + (index * this.renderedColumnsCount) / properties.length + ); + groupedProperties[columnIndex].elements.push(property); + }); + } + + return groupedProperties; } /** @@ -173,6 +230,15 @@ export class PropertiesField extends Component { return `property_${uuid()}`; } + /** + * Generate a new property name. + * + * @returns {string} + */ + generatePropertyName() { + return uuid(); + } + /* -------------------------------------------------------- * Event handlers * -------------------------------------------------------- */ @@ -183,7 +249,7 @@ export class PropertiesField extends Component { * @param {string} propertyName * @param {string} direction, either "up" or "down" */ - onPropertyMove(propertyName, direction) { + async onPropertyMove(propertyName, direction) { const propertiesValues = this.propertiesList || []; const propertyIndex = propertiesValues.findIndex( (property) => property.name === propertyName @@ -205,10 +271,12 @@ export class PropertiesField extends Component { propertiesValues[targetIndex] = propertiesValues[propertyIndex]; propertiesValues[propertyIndex] = prop; propertiesValues[propertyIndex].definition_changed = true; - this.props.record.update({ [this.props.name]: propertiesValues }).then(() => { - // move the popover once the DOM is updated - this.shouldUpdatePopoverPosition = true; - }); + + this._unfoldPropertyGroup(targetIndex, propertiesValues); + + await this.props.record.update({ [this.props.name]: propertiesValues }); + // move the popover once the DOM is updated + this.movePopoverToProperty = propertyName; } /** @@ -256,17 +324,45 @@ export class PropertiesField extends Component { * * @param {object} propertyDefinition */ - onPropertyDefinitionChange(propertyDefinition) { + async onPropertyDefinitionChange(propertyDefinition) { propertyDefinition["definition_changed"] = true; + if (propertyDefinition.type === "separator") { + // remove all other keys + propertyDefinition = pick( + propertyDefinition, + "name", + "string", + "definition_changed", + "type" + ); + } const propertiesValues = this.propertiesList; - const propertyIndex = propertiesValues.findIndex( - (property) => property.name === propertyDefinition.name - ); + const propertyIndex = this._getPropertyIndex(propertyDefinition.name); + + const oldType = propertiesValues[propertyIndex].type; + const newType = propertyDefinition.type; this._regeneratePropertyName(propertyDefinition); propertiesValues[propertyIndex] = propertyDefinition; - this.props.record.update({ [this.props.name]: propertiesValues }); + await this.props.record.update({ [this.props.name]: propertiesValues }); + + if (newType === "separator" && oldType !== "separator") { + // unfold automatically the new separator + this._unfoldSeparators([propertyDefinition.name], true); + // layout has been changed, move the definition popover + this.movePopoverToProperty = propertyDefinition.name; + } else if (oldType === "separator" && newType !== "separator") { + // unfold automatically the previous separator + const previousSeperator = propertiesValues.findLast( + (property, index) => index < propertyIndex && property.type === "separator" + ); + if (previousSeperator) { + this._unfoldSeparators([previousSeperator.name], true); + } + // layout has been changed, move the definition popover + this.movePopoverToProperty = propertyDefinition.name; + } } /** @@ -308,7 +404,9 @@ export class PropertiesField extends Component { if ( propertiesDefinitions.length && - propertiesDefinitions.some((prop) => !prop.string || !prop.string.length) + propertiesDefinitions.some( + (prop) => prop.type !== "separator" && (!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"); @@ -319,19 +417,34 @@ export class PropertiesField extends Component { return; } + this._unfoldPropertyGroup(propertiesDefinitions.length - 1, propertiesDefinitions); + this.propertiesRef.el.closest(".o_field_properties").classList.remove("o_field_invalid"); + const newName = this.generatePropertyName(); propertiesDefinitions.push({ - name: uuid(), + name: newName, string: _t("Property %s", propertiesDefinitions.length + 1), type: "char", definition_changed: true, }); - this.openLastPropertyDefinition = true; + this.openPropertyDefinition = newName; this.state.hideAddButton = false; this.props.record.update({ [this.props.name]: propertiesDefinitions }); } + /** + * Fold / unfold the given separator property. + * + * @param {string} propertyName, Name of the separator property + * @param {boolean} forceUnfold, Always unfold + */ + onSeparatorClick(propertyName) { + if (propertyName) { + this._unfoldSeparators([propertyName]); + } + } + /** * Verify that we can write on properties, we can not change the definition * if we don't have access for parent or if no parent is set. @@ -378,6 +491,59 @@ export class PropertiesField extends Component { * Private methods * -------------------------------------------------------- */ + /** + * Generate the key to get the fold state from the local storage. + * + * @returns {string} + */ + _getSeparatorFoldKey() { + const definitionRecordId = this.props.record.data[this.definitionRecordField][0]; + const definitionRecordModel = this.props.record.fields[this.definitionRecordField].relation; + // store the fold / unfold information per definition record + // to clean the keys (to not keep information about removed separator) + return `properties.fold,${definitionRecordModel},${definitionRecordId}`; + } + + /** + * Read the local storage and return the fold state stored in it. + * + * We clean the dictionary state because a property might have been deleted, + * and so there's no reason to keep the corresponding key in the dict. + * + * @returns {array} The folded state (name of the properties unfolded) + */ + _getUnfoldedSeparators() { + const key = this._getSeparatorFoldKey(); + const unfoldedSeparators = JSON.parse(window.localStorage.getItem(key)) || []; + const allPropertiesNames = this.propertiesList.map((property) => property.name); + // remove element that do not exist anymore (e.g. if we remove a separator) + return unfoldedSeparators.filter((name) => allPropertiesNames.includes(name)); + } + + /** + * Switch the folded state of the given separators. + * + * @param {array} separatorNames, list of separator name to fold / unfold + * @param {boolean} (forceUnfold) force the separator to be unfolded + */ + _unfoldSeparators(separatorNames, forceUnfold) { + let unfoldedSeparators = this._getUnfoldedSeparators(); + for (const separatorName of separatorNames) { + if (unfoldedSeparators.includes(separatorName)) { + if (!forceUnfold) { + unfoldedSeparators = unfoldedSeparators.filter( + (name) => name !== separatorName + ); + } + } else { + unfoldedSeparators.push(separatorName); + } + } + const key = this._getSeparatorFoldKey(); + window.localStorage.setItem(key, JSON.stringify(unfoldedSeparators)); + this.state.unfoldedSeparators = unfoldedSeparators; + } + /** * Move the popover to the given property id. * Used when we change the position of the properties. @@ -386,17 +552,17 @@ export class PropertiesField extends Component { * because if we update it after changing the component properties, */ _movePopoverIfNeeded() { - if (!this.shouldUpdatePopoverPosition) { + if (!this.movePopoverToProperty) { return; } - this.shouldUpdatePopoverPosition = false; + const propertyName = this.movePopoverToProperty; + this.movePopoverToProperty = null; - 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}"] .o_field_property_open_popover` + `*[property-name="${propertyName}"] .o_field_property_open_popover` ); reposition(targetElement, popover, null, { position: "top", margin: 10 }); @@ -452,12 +618,29 @@ export class PropertiesField extends Component { // and the python field will just ignore the old value. // Store the new generated name to be able to restore it // if needed. - const newName = uuid(); + const newName = this.generatePropertyName(); this.initialValues[newName] = initialValues; propertyDefinition.name = newName; } } + /** + * Find the index of the given property in the list. + * + * Care about new name generation, if the name changed (because + * the type of the property, the model, etc changed), it will + * still find the index of the original property. + * + * @params {string} propertyName + * @returns {integer} + */ + _getPropertyIndex(propertyName) { + const initialName = this.initialValues[propertyName]?.name || propertyName; + return this.propertiesList.findIndex((property) => + [propertyName, initialName].includes(property.name) + ); + } + /** * 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. @@ -551,6 +734,21 @@ export class PropertiesField extends Component { this.props.value = propertiesValues; this.props.record.update({ [this.props.name]: propertiesValues }); } + + /** + * Unfold the group of the given property. + * + * @param {integer} targetIndex + * @param {object} propertiesValues + */ + _unfoldPropertyGroup(targetIndex, propertiesValues) { + const separator = propertiesValues.findLast( + (property, index) => property.type === "separator" && index <= targetIndex + ); + if (separator) { + this._unfoldSeparators([separator.name], true); + } + } } export const 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 index 239f1193550..c263f710427 100644 --- a/addons/web/static/src/views/fields/properties/properties_field.scss +++ b/addons/web/static/src/views/fields/properties/properties_field.scss @@ -42,38 +42,28 @@ } } -.o_property_field { - .popover-body { - min-width: 500px; - } +.o_property_field_highlight .o_field_property_empty_label, +.o_property_field_highlight { + border: 1px solid $primary; + padding: 5px; + margin: -5px; + border-radius: 3px; +} - .o_field_property_label { - &: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_field_property_label { + width: 150px; - .o-dropdown { - .dropdown-menu { - max-height: 300px; - } + &:hover .o_field_property_open_popover, + .o_field_property_open_popover:focus { + opacity: 1; } - - .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_open_popover { + opacity: 0; + cursor: pointer; + transition: 0.1s; + } + .o_field_property_open_popover:hover { + color: $primary; } } @@ -83,6 +73,13 @@ margin-left: -1rem; } +.o_property_folded { + .o_field_property_label, + .o_property_field_value { + display: none !important; + } +} + .o_property_field_popover { // put the popover behind datetime component z-index: $zindex-popover !important; @@ -100,3 +97,10 @@ pointer-events: none; } } + +.o_field_property_group_label { + cursor: pointer; + user-select: none; + height: 20px; + box-shadow: 0 $border-width 0 $o-form-separator-color; +} diff --git a/addons/web/static/src/views/fields/properties/properties_field.xml b/addons/web/static/src/views/fields/properties/properties_field.xml index e84afc140ab..c979320009b 100644 --- a/addons/web/static/src/views/fields/properties/properties_field.xml +++ b/addons/web/static/src/views/fields/properties/properties_field.xml @@ -1,24 +1,48 @@ -
+
+ + -
+ + +
+
+ + +
+ +
+
-
+
-
@@ -156,7 +156,8 @@ onChange.bind="onDefaultChange" />
-
+