[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
This commit is contained in:
std-odoo
2023-08-29 09:11:51 +00:00
parent 89c93aba75
commit 29c7b23e22
10 changed files with 771 additions and 89 deletions
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 252 B

@@ -13,6 +13,7 @@ import { usePopover } from "@web/core/popover/popover_hook";
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { reposition } from "@web/core/position_hook";
import { archParseBoolean } from "@web/views/utils";
import { pick } from "@web/core/utils/objects";
import { Component, useRef, useState, useEffect, onWillStart } from "@odoo/owl";
@@ -46,41 +47,55 @@ export class PropertiesField extends Component {
});
this.propertiesRef = useRef("properties");
this.state = useState({
canChangeDefinition: true,
movedPropertyName: null,
hideAddButton: this.props.hideAddButton,
});
this._saveInitialPropertiesValues();
const field = this.props.record.fields[this.props.name];
this.definitionRecordField = field.definition_record;
this.state = useState({
canChangeDefinition: true,
movedPropertyName: null,
hideAddButton: this.props.hideAddButton,
unfoldedSeparators: this._getUnfoldedSeparators(),
});
onWillStart(async () => {
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<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 = {
@@ -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;
}
@@ -1,24 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.PropertiesField">
<div t-ref="properties" class="row align-items-start"
t-att-class="{'d-none': propertiesList.length === 0 and state.hideAddButton}">
<div t-ref="properties" class="row"
t-att-class="{'d-none': propertiesList.length === 0 and state.hideAddButton}"
t-att-columns="renderedColumnsCount">
<t t-set="unfoldedSeparators" t-value="state.unfoldedSeparators"/>
<t t-set="_groupedPropertiesList" t-value="groupedPropertiesList"/>
<t t-foreach="groupedPropertiesList" t-as="propertiesListGroup" t-key="propertiesListGroup_index">
<div class="o_inner_group grid col-lg-6">
<t t-set="foldable" t-value="propertiesListGroup.name and propertiesListGroup.title and propertiesListGroup.title.length"/>
<t t-set="isFolded" t-value="propertiesListGroup.name and !unfoldedSeparators.includes(propertiesListGroup.name)"/>
<div class="o_inner_group o_group col-lg-6 o_property_group"
t-att-property-name="propertiesListGroup.name || ''">
<div t-if="!propertiesListGroup.invisibleLabel"
class="o_field_property_label o_field_property_group_label d-flex flex-row w-100 mb-3 text-uppercase fw-bolder align-items-baseline pe-2"
t-att-class="{'invisible': propertiesListGroup.columnSeparator}"
t-on-click="() => this.onSeparatorClick(propertiesListGroup.name)">
<span t-if="propertiesListGroup.title" t-out="propertiesListGroup.title"/>
<i
t-if="state.canChangeDefinition and !props.readonly and !propertiesListGroup.columnSeparator"
class="o_field_property_open_popover fa fa-pencil ms-3"
t-on-click.prevent.stop="(event) => this.onPropertyEdit(event, propertiesListGroup.name)"/>
<div t-if="foldable" class="ms-auto">
<i class="fa me-1" t-att-class="isFolded ? 'fa-caret-right' : 'fa-caret-down'"/>
</div>
</div>
<div
t-foreach="propertiesListGroup"
t-foreach="propertiesListGroup.elements"
t-as="propertyConfiguration"
t-key="propertyConfiguration.name"
class="o_property_field d-contents mb-2"
class="o_property_field o_wrap_label"
t-att-class="{
'd-flex flex-row': !env.isSmall and !isFolded, 'o_property_folded': isFolded,
'mb-4': renderedColumnsCount === 1, 'mb-3': renderedColumnsCount !== 1
}"
t-att-property-name="propertyConfiguration.name">
<t t-set="domId" t-value="generateUniqueDomID()"/>
<label
t-attf-class="o_field_property_label o_form_label text-break {{state.movedPropertyName === propertyConfiguration.name ? 'o_property_field_highlight' : ''}} d-flex align-items-baseline"
class="o_field_property_label o_form_label text-break d-flex align-items-baseline mb-0"
t-att-class="{'w-auto': env.isSmall}"
t-att-for="domId">
<b
<span
t-if="propertyConfiguration.string and propertyConfiguration.string.length"
t-out="propertyConfiguration.string"
class="text-900"/>
t-att-class="{'o_property_field_highlight': state.movedPropertyName === propertyConfiguration.name}"/>
<i
t-else="" class="o_field_property_empty_label">
New Property
@@ -28,7 +52,7 @@
class="o_field_property_open_popover fa fa-pencil ms-2"
t-on-click="(event) => this.onPropertyEdit(event, propertyConfiguration.name)"/>
</label>
<div class="o_property_field_value">
<div class="o_property_field_value w-100">
<PropertyValue
id="domId"
canChangeDefinition="state.canChangeDefinition"
@@ -48,7 +72,7 @@
</div>
</div>
<div
t-if="propertiesList.length % props.columns === propertiesListGroup_index"
t-if="propertiesListGroup_index === _groupedPropertiesList.length - 1"
class="o_field_property_add"
t-att-class="{'g-col-2': props.columns !== 1, 'd-none': state.hideAddButton}">
<button
@@ -90,6 +90,7 @@ export class PropertyDefinition extends Component {
["tags", _t("Tags")],
["many2one", _t("Many2one")],
["many2many", _t("Many2many")],
["separator", _t("Separator")],
];
}
@@ -133,7 +133,7 @@
</div>
</div>
</div>
<div t-if="!['tags', 'selection'].includes(state.propertyDefinition.type)
<div t-if="!['tags', 'selection', 'separator'].includes(state.propertyDefinition.type)
and (!['many2one', 'many2many'].includes(state.propertyDefinition.type)
|| (state.propertyDefinition.comodel and state.propertyDefinition.comodel.length))"
class="o_field_property_definition_value d-contents mb-3 mb-sm-0">
@@ -156,7 +156,8 @@
onChange.bind="onDefaultChange"
/>
</div>
<div t-if="!props.hideKanbanOption" class="o_field_property_definition_kanban d-contents mb-3 mb-sm-0">
<div t-if="!props.hideKanbanOption and state.propertyDefinition.type !== 'separator'"
class="o_field_property_definition_kanban d-contents mb-3 mb-sm-0">
<label t-att-for="getUniqueDomID('kanban')" class="o_form_label align-self-center text-900">
Display in Cards
<sup class="text-info" title="Whether or not this Property Field is displayed in the Calendar, Cards &amp; Kanban views">?</sup>
@@ -22,6 +22,7 @@ import {
getPickerCell,
getTimePickers,
} from "../../core/datetime/datetime_test_helpers";
import { PropertiesField } from "@web/views/fields/properties/properties_field";
let serverData;
let target;
@@ -34,6 +35,7 @@ async function closePopover(target) {
async function changeType(target, propertyType) {
const TYPES_INDEX = {
char: 1,
integer: 3,
float: 4,
datetime: 6,
@@ -41,6 +43,7 @@ async function changeType(target, propertyType) {
tags: 8,
many2one: 9,
many2many: 10,
separator: 11,
};
const propertyTypeIndex = TYPES_INDEX[propertyType];
await click(target, ".o_field_property_definition_type input");
@@ -51,6 +54,94 @@ async function changeType(target, propertyType) {
);
}
// -----------------------------------------
// Separators tests utils
// -----------------------------------------
async function makePropertiesGroupView(properties) {
// mock random function to have predictable auto generated properties names
let counter = 1;
patchWithCleanup(PropertiesField.prototype, {
generatePropertyName: () => {
counter++;
return `property_gen_${counter}`;
},
});
async function mockRPC(route, { method }) {
if (["check_access_rights", "check_access_rule"].includes(method)) {
return true;
}
}
const data = JSON.parse(JSON.stringify(serverData));
data.models.partner.records[1].properties = properties.map((isSeparator, index) => {
return {
name: `property_${index + 1}`,
string: isSeparator ? `Separator ${index + 1}` : `Property ${index + 1}`,
type: isSeparator ? "separator" : "char",
};
});
// unfold all separators
window.localStorage.setItem(
"properties.fold,company,37",
JSON.stringify(
data.models.partner.records[1].properties
.filter((property) => property.type === "separator")
.map((property) => property.name)
)
);
// clean other element
window.localStorage.removeItem("properties.fold,fake.model,1337");
return await makeView({
type: "form",
resModel: "partner",
resId: 2,
serverData: data,
arch: `
<form>
<sheet>
<group>
<field name="company_id"/>
<field name="properties" columns="2"/>
</group>
</sheet>
</form>`,
mockRPC,
});
}
async function toggleSeparator(separatorName, isSeparator) {
await click(target, `[property-name="${separatorName}"] > * > .o_field_property_open_popover`);
await changeType(target, isSeparator ? "separator" : "char");
await closePopover(target);
}
function getGroups() {
const propertiesField = target.querySelector(".o_field_properties .row");
const groups = propertiesField.querySelectorAll(".o_property_group");
return [...groups].map((group) => [
[
group.querySelector(".o_field_property_group_label")?.innerText || "",
group.getAttribute("property-name"),
],
...[...group.querySelectorAll("[property-name]:not(.o_property_folded)")].map(
(property) => [property.innerText, property.getAttribute("property-name")]
),
]);
}
function getLocalStorageFold() {
return {
"company,37": JSON.parse(window.localStorage.getItem("properties.fold,company,37")) || [],
"fake.model,1337":
JSON.parse(window.localStorage.getItem("properties.fold,fake.model,1337")) || [],
};
}
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
@@ -424,12 +515,11 @@ QUnit.module("Fields", (hooks) => {
await closePopover(target);
const properties = field.querySelectorAll(".o_property_field");
const properties = field.querySelectorAll(".o_field_property_label");
assert.strictEqual(properties.length, 3);
const newProperty = properties[2];
const newPropertyLabel = newProperty.querySelector(".o_field_property_label");
assert.strictEqual(newPropertyLabel.innerText, "Property 3");
assert.strictEqual(newProperty.innerText, "Property 3");
});
/**
@@ -1887,4 +1977,334 @@ QUnit.module("Fields", (hooks) => {
assert.ok(target.querySelector(".o_test_properties_not_empty"));
}
);
// ---------------------------------------------------
// Test the properties groups
// ---------------------------------------------------
QUnit.test("properties: separators layout", async function (assert) {
await makePropertiesGroupView([false, false, false, false]);
await toggleSeparator("property_1", true);
assert.deepEqual(getGroups(), [
[
["PROPERTY 1", "property_gen_2"],
["Property 2", "property_2"],
["Property 3", "property_3"],
],
[
["", "property_gen_2"],
["Property 4", "property_4"],
],
]);
// fold the group
await click(
target,
".o_field_properties .o_property_group[property-name='property_gen_2']:first-child .o_field_property_group_label"
);
assert.deepEqual(getGroups(), [
[["PROPERTY 1", "property_gen_2"]],
[["", "property_gen_2"]],
]);
await click(
target,
".o_field_properties .o_property_group[property-name='property_gen_2']:first-child .o_field_property_group_label"
);
await toggleSeparator("property_3", true);
assert.deepEqual(getGroups(), [
[
["PROPERTY 1", "property_gen_2"],
["Property 2", "property_2"],
],
[
["PROPERTY 3", "property_gen_3"],
["Property 4", "property_4"],
],
]);
// fold the left group
await click(
target,
".o_property_group[property-name='property_gen_2'] .o_field_property_group_label"
);
assert.deepEqual(getGroups(), [
[["PROPERTY 1", "property_gen_2"]],
[
["PROPERTY 3", "property_gen_3"],
["Property 4", "property_4"],
],
]);
await click(
target,
".o_property_group[property-name='property_gen_2'] .o_field_property_group_label"
);
// create 3 new properties
await click(target, ".o_field_property_add button");
await click(target, ".o_field_property_add button");
await click(target, ".o_field_property_add button");
await nextTick();
await closePopover(target);
assert.deepEqual(getGroups(), [
[
["PROPERTY 1", "property_gen_2"],
["Property 2", "property_2"],
],
[
["PROPERTY 3", "property_gen_3"],
["Property 4", "property_4"],
["Property 5", "property_gen_4"],
["Property 6", "property_gen_5"],
["Property 7", "property_gen_6"],
],
]);
// Property 3 is not a separator anymore, should split in columns
await toggleSeparator("property_gen_3", false);
assert.deepEqual(getGroups(), [
[
["PROPERTY 1", "property_gen_2"],
["Property 2", "property_2"],
["Property 3", "property_3"],
["Property 4", "property_4"],
],
[
// invisible separator to fill the space
["", "property_gen_2"],
["Property 5", "property_gen_4"],
["Property 6", "property_gen_5"],
["Property 7", "property_gen_6"],
],
]);
// Property 1 is not a separator anymore, there's no separator left,
// should go back to the original layout
await toggleSeparator("property_gen_2", false);
assert.deepEqual(getGroups(), [
[
["", ""],
["Property 1", "property_1"],
["Property 2", "property_2"],
["Property 3", "property_3"],
["Property 4", "property_4"],
],
[
["", ""],
["Property 5", "property_gen_4"],
["Property 6", "property_gen_5"],
["Property 7", "property_gen_6"],
],
]);
});
QUnit.test("properties: separators and local storage", async function (assert) {
await makePropertiesGroupView([false, false, false, false, true, false]);
// store the fold state of an other properties field to verify that it stay untouched
// and check that the property that doesn't exist is removed
window.localStorage.setItem("company,37", JSON.stringify(["fake"]));
window.localStorage.setItem(
"properties.fold,fake.model,1337",
JSON.stringify(["a", "b", "c"])
);
assert.deepEqual(getGroups(), [
[
["", ""],
["Property 1", "property_1"],
["Property 2", "property_2"],
["Property 3", "property_3"],
["Property 4", "property_4"],
],
[
["SEPARATOR 5", "property_5"],
["Property 6", "property_6"],
],
]);
// fold the group
await click(target, "div[property-name='property_5'] .o_field_property_group_label");
assert.deepEqual(getGroups(), [
[
["", ""],
["Property 1", "property_1"],
["Property 2", "property_2"],
["Property 3", "property_3"],
["Property 4", "property_4"],
],
[["SEPARATOR 5", "property_5"]],
]);
assert.deepEqual(getLocalStorageFold(), {
"company,37": [],
"fake.model,1337": ["a", "b", "c"], // stay untouched
});
// unfold the group
await click(target, "div[property-name='property_5'] .o_field_property_group_label");
assert.deepEqual(getLocalStorageFold(), {
"company,37": ["property_5"],
"fake.model,1337": ["a", "b", "c"], // stay untouched
});
});
/**
* Test the behavior of the properties when we move them inside folded groups
*/
QUnit.test("properties: separators move properties", async function (assert) {
await makePropertiesGroupView([false, true, true, false, true, true, false]);
// return true if the given separator is folded
const foldState = (separatorName) => {
return !target.querySelector(
`div[property-name='${separatorName}'] .o_field_property_label .fa-caret-down`
);
};
const assertFolded = (values) => {
assert.strictEqual(values.length, 4);
assert.strictEqual(values[0], foldState("property_2"));
assert.strictEqual(values[1], foldState("property_3"));
assert.strictEqual(values[2], foldState("property_5"));
assert.strictEqual(values[3], foldState("property_6"));
};
// fold all groups
assertFolded([false, false, false, false]);
await click(target, "div[property-name='property_2'] .o_field_property_group_label");
await click(target, "div[property-name='property_3'] .o_field_property_group_label");
await click(target, "div[property-name='property_5'] .o_field_property_group_label");
await click(target, "div[property-name='property_6'] .o_field_property_group_label");
assertFolded([true, true, true, true]);
assert.deepEqual(getGroups(), [
[
["", ""],
["Property 1", "property_1"],
],
[["SEPARATOR 2", "property_2"]],
[["SEPARATOR 3", "property_3"]],
[["SEPARATOR 5", "property_5"]],
[["SEPARATOR 6", "property_6"]],
]);
// move the first property down
await click(target, "[property-name='property_1'] .o_field_property_open_popover");
await click(target, ".o_field_property_definition .oi-chevron-down");
assert.deepEqual(getGroups(), [
[
["SEPARATOR 2", "property_2"],
["Property 1", "property_1"],
],
[["SEPARATOR 3", "property_3"]],
[["SEPARATOR 5", "property_5"]],
[["SEPARATOR 6", "property_6"]],
]);
assertFolded([false, true, true, true]);
await click(target, ".o_field_property_definition .oi-chevron-down");
assert.deepEqual(getGroups(), [
[["SEPARATOR 2", "property_2"]],
[
["SEPARATOR 3", "property_3"],
["Property 1", "property_1"],
["Property 4", "property_4"],
],
[["SEPARATOR 5", "property_5"]],
[["SEPARATOR 6", "property_6"]],
]);
assertFolded([false, false, true, true]);
await click(target, ".o_field_property_definition .oi-chevron-down");
assert.deepEqual(getGroups(), [
[["SEPARATOR 2", "property_2"]],
[
["SEPARATOR 3", "property_3"],
["Property 4", "property_4"],
["Property 1", "property_1"],
],
[["SEPARATOR 5", "property_5"]],
[["SEPARATOR 6", "property_6"]],
]);
assertFolded([false, false, true, true]);
await click(target, ".o_field_property_definition .oi-chevron-down");
assert.deepEqual(getGroups(), [
[["SEPARATOR 2", "property_2"]],
[
["SEPARATOR 3", "property_3"],
["Property 4", "property_4"],
],
[
["SEPARATOR 5", "property_5"],
["Property 1", "property_1"],
],
[["SEPARATOR 6", "property_6"]],
]);
assertFolded([false, false, false, true]);
// fold property 2 and 3
await closePopover(target);
await click(target, "div[property-name='property_2'] .o_field_property_group_label");
await click(target, "div[property-name='property_3'] .o_field_property_group_label");
assertFolded([true, true, false, true]);
// move the property up
await click(target, "[property-name='property_1'] .o_field_property_open_popover");
await click(target, ".o_field_property_definition .oi-chevron-up");
assert.deepEqual(getGroups(), [
[["SEPARATOR 2", "property_2"]],
[
["SEPARATOR 3", "property_3"],
["Property 4", "property_4"],
["Property 1", "property_1"],
],
[["SEPARATOR 5", "property_5"]],
[["SEPARATOR 6", "property_6"]],
]);
assertFolded([true, false, false, true]);
await click(target, ".o_field_property_definition .oi-chevron-up");
await click(target, ".o_field_property_definition .oi-chevron-up");
assert.deepEqual(getGroups(), [
[
["SEPARATOR 2", "property_2"],
["Property 1", "property_1"],
],
[
["SEPARATOR 3", "property_3"],
["Property 4", "property_4"],
],
[["SEPARATOR 5", "property_5"]],
[["SEPARATOR 6", "property_6"]],
]);
assertFolded([false, false, false, true]);
// now, create a new property, it must unfold the last group
await click(target, ".o_field_property_add button", { skipVisibilityCheck: true });
assert.deepEqual(getGroups(), [
[
["SEPARATOR 2", "property_2"],
["Property 1", "property_1"],
],
[
["SEPARATOR 3", "property_3"],
["Property 4", "property_4"],
],
[["SEPARATOR 5", "property_5"]],
[
["SEPARATOR 6", "property_6"],
["Property 7", "property_7"],
["Property 8", "property_gen_2"],
],
]);
assertFolded([false, false, false, false]);
assert.deepEqual(getLocalStorageFold(), {
"company,37": ["property_5", "property_3", "property_2", "property_6"],
"fake.model,1337": [],
});
});
});
@@ -952,6 +952,29 @@ class PropertiesCase(TestPropertiesMixin):
self.assertEqual(values.get('name'), 'new_selection')
self.assertEqual(values.get('selection'), [], 'Selection key should be at least an empty array (never False)')
def test_properties_field_separator(self):
"""Test the separator properties."""
self.message_1.attributes = [
{'name': 'boolean_value', 'value': 0, 'type': 'boolean', 'definition_changed': True},
{'type': 'separator', 'name': 'separator', 'string': 'Group 1'},
{'name': 'int_value', 'value': 0, 'type': 'integer'},
]
sql_definition = self._get_sql_definition(self.discussion_1)
self.assertEqual(
sql_definition,
[
{'name': 'boolean_value', 'type': 'boolean'},
{'name': 'separator', 'type': 'separator', 'string': 'Group 1'},
{'name': 'int_value', 'type': 'integer'},
],
)
sql_values = self._get_sql_properties(self.message_1)
self.assertEqual(
sql_values, {'int_value': False, 'boolean_value': False},
msg='Separator should never be stored on the children, only in the definition record')
def test_properties_field_tags(self):
"""Test the behavior of the tag property.
+10 -2
View File
@@ -3269,8 +3269,12 @@ class Properties(Field):
_description_definition_record_field = property(attrgetter('definition_record_field'))
ALLOWED_TYPES = (
'boolean', 'integer', 'float', 'char', 'date',
'datetime', 'many2one', 'many2many', 'selection', 'tags',
# standard types
'boolean', 'integer', 'float', 'char', 'date', 'datetime',
# relational like types
'many2one', 'many2many', 'selection', 'tags',
# UI types
'separator',
)
def _setup_attrs(self, model_class, name):
@@ -3741,6 +3745,10 @@ class Properties(Field):
property_type = property_definition.get('type')
property_model = property_definition.get('comodel')
if property_type == 'separator':
# "separator" is used as a visual separator in the form view UI
# it does not have a value and does not need to be stored on children
continue
if property_type not in ('integer', 'float') or property_value != 0:
property_value = property_value or False
if property_type in ('many2one', 'many2many') and property_model and property_value: