[IMP] web: DomainSelector improvements

The objective of this commit is twofold:

- improve the style of the DomainSelector component, in particular, the
  style of the control buttons "Add node", "Add branch", and "Delete node".

- add the possibility to reset the domain when the current domain is not
  supported. The debug input is also always visible in that case
  if the debug mode is active. It is displayed in readonly iff the
  corresponding DomainSelector prop is set to true.

Task ID: 3269923

closes odoo/odoo#118057

Related: odoo/enterprise#39512
Signed-off-by: Géry Debongnie <ged@odoo.com>
Co-authored-by: Mathieu Duckerts-Antoine <dam@odoo.com>
Co-authored-by: Michaël Mattiello <mcm@odoo.com>
This commit is contained in:
Mathieu Duckerts-Antoine
2023-04-13 17:57:14 +02:00
co-authored by Michaël Mattiello
parent 1c374b9b05
commit b307e360be
7 changed files with 448 additions and 197 deletions
@@ -51,6 +51,12 @@ export class DomainSelector extends Component {
onWillUpdateProps((np) => this.onPropsUpdated(np));
}
get className() {
return `${this.props.readonly ? "o_read_mode" : "o_edit_mode"} ${
this.props.className
}`.trim();
}
async onPropsUpdated(p) {
try {
// try to parse and execute the domain, if it fails then the domain is not supported.
@@ -67,7 +73,7 @@ export class DomainSelector extends Component {
this.tree.isSupported = true;
} catch {
this.tree.isSupported = false;
this.tree.root = null;
this.tree.root = this.treeBuilder.build(new Domain([]), {});
}
}
@@ -0,0 +1,194 @@
.o_domain_selector:not(.o_domain_selector_legacy) {
cursor: default;
&.o_domain_node, .o_domain_node {
$o-domain-selector-indent: 32px;
$o-domain-selector-panel-space: 60px;
$o-domain-selector-row-height: 35px; // should be greater than a "normal" row so that "header" parts are the same size of rows
$o-domain-animation-bar-height: 15px;
position: relative;
// Rows in a domain node (selector) have a minimum height and holds the control panel
.o_domain_selector_row {
display: flex;
align-items: center;
min-height: $o-domain-selector-row-height;
> * {
flex: 0 0 auto;
}
> .o_domain_node_control_panel {
@include o-position-absolute($top: 0, $bottom: 0, $right: -$o-domain-selector-panel-space);
> button {
float: left;
width: $o-domain-selector-panel-space/2;
height: 100%;
padding: 0;
background-color: transparent;
font-size: 16px;
opacity: 0.2;
cursor: pointer;
&.o_domain_delete_node_button {
font-size: 12px;
padding-left: 5px;
}
}
}
}
// Rules specific to domain parts which contain sub-domain parts
&.o_domain_tree {
> .o_domain_tree_header {
position: relative;
}
.o_domain_tree_operator_caret::after {
@include o-caret-down;
}
& .o_domain_node_children_container {
padding-left: $o-domain-selector-indent;
}
// Rules specific to domain root
&.o_domain_selector {
> .o_domain_tree_header {
min-height: 22px; // prevents "records button" to overflow 1-row domain
}
> .o_domain_node_children_container {
padding-left: 0;
}
&.o_edit_mode > .o_domain_node_children_container {
padding-right: $o-domain-selector-panel-space; // delete button is over the row
}
> .o_domain_debug_container {
display: block;
margin-top: 16px;
padding: 8px 10px 12px;
background: var(--DomainDebugContainer-background-colomap-get, #{$o-gray-900});
color: rgba(white, 0.5);
font-family: monospace;
font-weight: normal;
> textarea {
border: none;
padding-top: 8px;
background: transparent;
color: white;
}
}
}
}
// Rules specific to domain parts which do not contain sub-domain parts
&.o_domain_leaf {
&.o_read_mode {
display: inline-flex;
margin-right: 4px;
}
> .o_domain_leaf_info {
background: $o-brand-lightsecondary;
border: 1px solid darken($o-brand-lightsecondary, 10%);
padding: 2px 4px;
> .o_field_selector {
float: left;
margin-right: 4px;
> .o_field_selector_value > .o_field_selector_chain_part {
border: none;
font-weight: 700;
}
}
.o_domain_leaf_operator {
font-style: italic;
}
.o_domain_leaf_value {
font-weight: 700;
}
}
> .o_domain_leaf_edition {
display: flex;
width: 100%;
padding: 4px ($o-domain-selector-panel-space/2) 4px 0;
> * {
width: auto;
background-color: white;
&.o_field_selector {
flex: 0 1 40%;
width: 40%;
> .o_field_selector_value > .o_field_selector_chain_part {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
&:last-child { // field value
flex: 1 1 25%;
}
+ * {
margin-left: 4px;
}
input, select, .o_datepicker, .o_datepicker_input {
height: 100%;
}
}
.o_domain_leaf_value_tags {
display: flex;
> * {
flex: 0 0 auto;
}
> input {
flex: 1 1 auto;
width: 0;
min-width: 50px;
}
.o_domain_leaf_value_remove_tag_button {
cursor: pointer;
}
}
}
}
// Animation effects
transition: margin-bottom .05s ease .05s;
&::after {
content: "";
@include o-position-absolute($left: 0, $right: 0, $top: 100%);
display: block;
height: $o-domain-animation-bar-height;
max-height: 0;
background-image: linear-gradient(45deg, rgba($o-brand-primary, 0.4), rgba($o-brand-primary, 0.1));
transition: max-height .05s ease .05s;
}
&.o_hover_btns, &.o_hover_add_node {
.o_domain_node_control_panel > button {
opacity: 0.5;
&:hover {
opacity: 1.0;
&.o_domain_delete_node_button {
color: var(--danger);
}
}
}
}
}
}
@@ -2,40 +2,38 @@
<templates xml:space="preserve">
<t t-name="web._DomainSelector" owl="1">
<t t-if="tree.isSupported">
<t t-call="web._DomainSelector.root">
<t t-set="node" t-value="tree.root" />
</t>
</t>
<t t-else="">
<div t-att-class="props.className">This domain is not supported.</div>
</t>
</t>
<t t-name="web._DomainSelector.root" owl="1">
<div class="o_domain_node o_domain_tree o_domain_selector" aria-atomic="true" t-attf-class="{{ props.className }} {{ props.readonly ? 'o_read_mode' : 'o_edit_mode'}}" t-ref="root">
<t t-if="node.children.length === 0">
<span>Match <strong>all records</strong></span>
<t t-if="!props.readonly">
<button class="btn btn-sm btn-primary o_domain_add_first_node_button ms-1" t-on-click="() => this.insertRootLeaf(node)">
<i class="fa fa-plus"/> Add filter
</button>
<div class="o_domain_node o_domain_tree o_domain_selector" aria-atomic="true" t-att-class="className" t-ref="root">
<t t-set="node" t-value="tree.root" />
<t t-if="tree.isSupported">
<t t-if="node.children.length === 0">
<span>Match <strong>all records</strong></span>
<t t-if="!props.readonly">
<button class="btn btn-sm btn-primary o_domain_add_first_node_button ms-1" t-on-click="() => this.insertRootLeaf(node)">
<i class="fa fa-plus"/> Add filter
</button>
</t>
</t>
<t t-elif="node.children.length === 1">
Match records with the following rule:
<t t-call="web._DomainSelector.branch.children" />
</t>
<t t-else="">
<span>Match records with </span>
<t t-call="web._DomainSelector.branch.operator" />
<span> of the following rules:</span>
<t t-call="web._DomainSelector.branch.children" />
</t>
</t>
<t t-elif="node.children.length === 1">
Match records with the following rule:
<t t-call="web._DomainSelector.branch.children" />
</t>
<t t-else="">
<span>Match records with </span>
<t t-call="web._DomainSelector.branch.operator" />
<span> of the following rules:</span>
<t t-call="web._DomainSelector.branch.children" />
This domain is not supported.
<t t-if="!props.readonly">
<button class="btn btn-sm btn-primary o_domain_add_first_node_button ms-1" t-on-click="() => this.insertRootLeaf(node)">Reset domain</button>
</t>
</t>
<t t-if="props.isDebugMode and !props.readonly">
<t t-if="props.isDebugMode and (!tree.isSupported or !props.readonly)">
<label class="o_domain_debug_container">
<span class="small"># Code editor</span>
<textarea type="text" class="o_domain_debug_input" spellcheck="false" t-att-value="props.debugValue or props.value" t-on-change="(ev) => this.onDebugValueChange(ev.target.value)" />
<textarea type="text" class="o_domain_debug_input" t-att-readonly="props.readonly" spellcheck="false" t-att-value="props.debugValue or props.value" t-on-change="(ev) => this.onDebugValueChange(ev.target.value)" />
</label>
</t>
</div>
@@ -43,16 +41,6 @@
<t t-name="web._DomainSelector.controls" owl="1">
<div class="o_domain_node_control_panel" role="toolbar" aria-label="Domain node">
<button
class="btn btn-link text-danger o_domain_delete_node_button"
title="Delete node"
aria-label="Delete node"
t-on-click="() => this.delete(parent, node)"
t-on-mouseenter="(ev) => this.highlightNode(ev.target, true, 'o_hover_btns')"
t-on-mouseleave="(ev) => this.highlightNode(ev.target, false, 'o_hover_btns')"
>
<i class="fa fa-times"/>
</button>
<button
class="btn o_domain_add_node_button"
title="Add node"
@@ -61,7 +49,7 @@
t-on-mouseenter="(ev) => this.highlightNode(ev.target, true, 'o_hover_add_node')"
t-on-mouseleave="(ev) => this.highlightNode(ev.target, false, 'o_hover_add_node')"
>
<i class="fa fa-plus-circle"/>
<i class="fa fa-plus"/>
</button>
<button
class="btn o_domain_add_node_button"
@@ -71,7 +59,17 @@
t-on-mouseenter="(ev) => this.highlightNode(ev.target, true, 'o_hover_add_node o_hover_add_inset_node')"
t-on-mouseleave="(ev) => this.highlightNode(ev.target, false, 'o_hover_add_node o_hover_add_inset_node')"
>
<i class="fa fa-ellipsis-h"/>
<i class="fa fa-sitemap"/>
</button>
<button
class="btn btn-link text-danger o_domain_delete_node_button"
title="Delete node"
aria-label="Delete node"
t-on-click="() => this.delete(parent, node)"
t-on-mouseenter="(ev) => this.highlightNode(ev.target, true, 'o_hover_btns')"
t-on-mouseleave="(ev) => this.highlightNode(ev.target, false, 'o_hover_btns')"
>
<i class="fa fa-trash"/>
</button>
</div>
</t>
@@ -161,7 +159,7 @@
<ModelFieldSelector fieldName="node.field.name" resModel="props.resModel" readonly="false" update="(field) => this.updateField(node, field)" isDebugMode="props.isDebugMode" />
<div>
<select class="o_domain_leaf_operator_select o_input text-truncate pe-1" t-on-change="(ev) => this.updateLeafOperator(node, ev.target.value)">
<select class="o_domain_leaf_operator_select o_input text-truncate pe-3" t-on-change="(ev) => this.updateLeafOperator(node, ev.target.value)">
<t t-foreach="getOperatorsInfo(node)" t-as="operator" t-key="operator.key">
<option t-att-value="operator.key" t-att-selected="node.operator.key === operator.key" t-esc="operator_value.label" />
</t>
@@ -1,181 +1,184 @@
.o_domain_node {
$o-domain-selector-indent: 32px;
$o-domain-selector-panel-space: 60px;
$o-domain-selector-row-height: 35px; // should be greater than a "normal" row so that "header" parts are the same size of rows
$o-domain-animation-bar-height: 15px;
.o_domain_selector_legacy {
position: relative;
&.o_domain_node, .o_domain_node {
$o-domain-selector-indent: 32px;
$o-domain-selector-panel-space: 60px;
$o-domain-selector-row-height: 35px; // should be greater than a "normal" row so that "header" parts are the same size of rows
$o-domain-animation-bar-height: 15px;
// Rows in a domain node (selector) have a minimum height and holds the control panel
.o_domain_selector_row {
display: flex;
align-items: center;
min-height: $o-domain-selector-row-height;
position: relative;
> * {
flex: 0 0 auto;
}
// Rows in a domain node (selector) have a minimum height and holds the control panel
.o_domain_selector_row {
display: flex;
align-items: center;
min-height: $o-domain-selector-row-height;
> .o_domain_node_control_panel {
@include o-position-absolute($top: 0, $bottom: 0, $right: -$o-domain-selector-panel-space);
> * {
flex: 0 0 auto;
}
> button {
float: left;
width: $o-domain-selector-panel-space/2;
height: 100%;
padding: 0;
background-color: transparent;
font-size: 16px;
opacity: 0.2;
cursor: pointer;
> .o_domain_node_control_panel {
@include o-position-absolute($top: 0, $bottom: 0, $right: -$o-domain-selector-panel-space);
&.o_domain_delete_node_button {
font-size: 12px;
padding-left: 5px;
> button {
float: left;
width: $o-domain-selector-panel-space/2;
height: 100%;
padding: 0;
background-color: transparent;
font-size: 16px;
opacity: 0.2;
cursor: pointer;
&.o_domain_delete_node_button {
font-size: 12px;
padding-left: 5px;
}
}
}
}
}
// Rules specific to domain parts which contain sub-domain parts
&.o_domain_tree {
> .o_domain_tree_header {
position: relative;
}
.o_domain_tree_operator_caret::after {
@include o-caret-down;
}
& .o_domain_node_children_container {
padding-left: $o-domain-selector-indent;
}
// Rules specific to domain root
&.o_domain_selector {
// Rules specific to domain parts which contain sub-domain parts
&.o_domain_tree {
> .o_domain_tree_header {
min-height: 22px; // prevents "records button" to overflow 1-row domain
position: relative;
}
.o_domain_tree_operator_caret::after {
@include o-caret-down;
}
> .o_domain_node_children_container {
padding-left: 0;
& .o_domain_node_children_container {
padding-left: $o-domain-selector-indent;
}
&.o_edit_mode > .o_domain_node_children_container {
padding-right: $o-domain-selector-panel-space; // delete button is over the row
}
// Rules specific to domain root
&.o_domain_selector {
> .o_domain_tree_header {
min-height: 22px; // prevents "records button" to overflow 1-row domain
}
> .o_domain_debug_container {
display: block;
margin-top: 16px;
padding: 8px 10px 12px;
background: var(--DomainDebugContainer-background-colomap-get, #{$o-gray-900});
color: rgba(white, 0.5);
font-family: monospace;
font-weight: normal;
> .o_domain_node_children_container {
padding-left: 0;
}
> textarea {
border: none;
padding-top: 8px;
background: transparent;
color: white;
&.o_edit_mode > .o_domain_node_children_container {
padding-right: $o-domain-selector-panel-space; // delete button is over the row
}
> .o_domain_debug_container {
display: block;
margin-top: 16px;
padding: 8px 10px 12px;
background: var(--DomainDebugContainer-background-colomap-get, #{$o-gray-900});
color: rgba(white, 0.5);
font-family: monospace;
font-weight: normal;
> textarea {
border: none;
padding-top: 8px;
background: transparent;
color: white;
}
}
}
}
}
// Rules specific to domain parts which do not contain sub-domain parts
&.o_domain_leaf {
&.o_read_mode {
display: inline-flex;
margin-right: 4px;
}
> .o_domain_leaf_info {
background: $o-brand-lightsecondary;
border: 1px solid darken($o-brand-lightsecondary, 10%);
padding: 2px 4px;
> .o_field_selector {
float: left;
// Rules specific to domain parts which do not contain sub-domain parts
&.o_domain_leaf {
&.o_read_mode {
display: inline-flex;
margin-right: 4px;
}
> .o_field_selector_value > .o_field_selector_chain_part {
border: none;
> .o_domain_leaf_info {
background: $o-brand-lightsecondary;
border: 1px solid darken($o-brand-lightsecondary, 10%);
padding: 2px 4px;
> .o_field_selector {
float: left;
margin-right: 4px;
> .o_field_selector_value > .o_field_selector_chain_part {
border: none;
font-weight: 700;
}
}
.o_domain_leaf_operator {
font-style: italic;
}
.o_domain_leaf_value {
font-weight: 700;
}
}
.o_domain_leaf_operator {
font-style: italic;
}
.o_domain_leaf_value {
font-weight: 700;
}
}
> .o_domain_leaf_edition {
display: flex;
width: 100%;
padding: 4px ($o-domain-selector-panel-space/2) 4px 0;
> * {
flex: 1 1 15%; // operator select
width: auto;
background-color: white;
&:first-child { // field selector
flex: 0 1 55%;
}
&:last-child { // field value
flex: 1 1 25%;
}
+ * {
margin-left: 4px;
}
input, select, .o_datepicker, .o_datepicker_input {
height: 100%;
}
}
.o_domain_leaf_value_tags {
> .o_domain_leaf_edition {
display: flex;
width: 100%;
padding: 4px ($o-domain-selector-panel-space/2) 4px 0;
> * {
flex: 0 0 auto;
flex: 1 1 15%; // operator select
width: auto;
background-color: white;
&:first-child { // field selector
flex: 0 1 55%;
}
&:last-child { // field value
flex: 1 1 25%;
}
+ * {
margin-left: 4px;
}
input, select, .o_datepicker, .o_datepicker_input {
height: 100%;
}
}
> input {
flex: 1 1 auto;
width: 0;
min-width: 50px;
}
.o_domain_leaf_value_remove_tag_button {
cursor: pointer;
.o_domain_leaf_value_tags {
display: flex;
> * {
flex: 0 0 auto;
}
> input {
flex: 1 1 auto;
width: 0;
min-width: 50px;
}
.o_domain_leaf_value_remove_tag_button {
cursor: pointer;
}
}
}
}
}
// Animation effects
transition: margin-bottom .05s ease .05s;
// Animation effects
transition: margin-bottom .05s ease .05s;
&::after {
content: "";
@include o-position-absolute($left: 0, $right: 0, $top: 100%);
display: block;
height: $o-domain-animation-bar-height;
max-height: 0;
background-image: linear-gradient(45deg, rgba($o-brand-primary, 0.4), rgba($o-brand-primary, 0.1));
transition: max-height .05s ease .05s;
}
&::after {
content: "";
@include o-position-absolute($left: 0, $right: 0, $top: 100%);
display: block;
height: $o-domain-animation-bar-height;
max-height: 0;
background-image: linear-gradient(45deg, rgba($o-brand-primary, 0.4), rgba($o-brand-primary, 0.1));
transition: max-height .05s ease .05s;
}
&.o_hover_btns, &.o_hover_add_node {
.o_domain_node_control_panel > button {
opacity: 0.5;
&:hover {
opacity: 1.0;
&.o_hover_btns, &.o_hover_add_node {
.o_domain_node_control_panel > button {
opacity: 0.5;
&:hover {
opacity: 1.0;
}
}
}
}
+1 -1
View File
@@ -673,7 +673,7 @@
<t t-if="widget.operator === '!'">NONE</t>
</strong>
</t>
<div aria-atomic="true" t-name="DomainSelector" t-attf-class="o_domain_node o_domain_tree o_domain_selector #{widget.readonly ? 'o_read_mode' : 'o_edit_mode'}">
<div aria-atomic="true" t-name="DomainSelector" t-attf-class="o_domain_node o_domain_tree o_domain_selector o_domain_selector_legacy #{widget.readonly ? 'o_read_mode' : 'o_edit_mode'}">
<t t-if="widget.children.length === 0">
<span>Match <strong>all records</strong></span>
<button t-if="!widget.readonly" class="btn btn-sm btn-primary o_domain_add_first_node_button"><i class="fa fa-plus"/> Add filter</button>
@@ -139,8 +139,8 @@ QUnit.module("Components", (hooks) => {
// There should be a "+" button to add a domain part; clicking on it
// should add the default "['id', '=', 1]" domain
assert.containsOnce(target, ".fa-plus-circle", "there should be a '+' button");
await click(target, ".fa-plus-circle");
assert.containsOnce(target, ".fa.fa-plus", "there should be a '+' button");
await click(target, ".fa.fa-plus");
assert.strictEqual(
target.querySelector(".o_domain_debug_input").value,
`["&", ("bar", "=", True), ("id", "=", 1)]`,
@@ -150,16 +150,16 @@ QUnit.module("Components", (hooks) => {
// There should be two "..." buttons to add a domain group; clicking on
// the first one, should add this group with defaults "['id', '=', 1]"
// domains and the "|" operator
assert.containsN(target, ".fa-ellipsis-h", 2, "there should be two '...' buttons");
assert.containsN(target, ".fa.fa-sitemap", 2, "there should be two '...' buttons");
await click(target.querySelector(".fa-ellipsis-h"));
await click(target.querySelector(".fa.fa-sitemap"));
assert.strictEqual(
target.querySelector(".o_domain_debug_input").value,
`["&", ("bar", "=", True), "&", "|", ("id", "=", 1), ("id", "=", 1), ("id", "=", 1)]`,
"the domain input should contain a domain with 'bar', 'id' and a subgroup"
);
// There should be five "-" buttons to remove domain part; clicking on
// There should be five buttons to remove domain part; clicking on
// the two last ones, should leave a domain with only the "bar" and
// "foo" fields, with the initial "&" operator
assert.containsN(
@@ -168,9 +168,9 @@ QUnit.module("Components", (hooks) => {
5,
"there should be five 'x' buttons"
);
let buttons = target.querySelectorAll(".o_domain_delete_node_button");
let buttons = target.querySelectorAll(".o_domain_delete_node_button .fa.fa-trash");
await click(buttons[buttons.length - 1]);
buttons = target.querySelectorAll(".o_domain_delete_node_button");
buttons = target.querySelectorAll(".o_domain_delete_node_button .fa.fa-trash");
await click(buttons[buttons.length - 1]);
assert.strictEqual(
target.querySelector(".o_domain_debug_input").value,
@@ -286,21 +286,17 @@ QUnit.module("Components", (hooks) => {
});
QUnit.test("editing a domain with `parent` key", async (assert) => {
assert.expect(1);
// Create the domain selector and its mock environment
await mountComponent(DomainSelector, {
props: {
resModel: "product",
value: `[("name", "=", parent.foo)]`,
readonly: false,
isDebugMode: true,
},
});
assert.strictEqual(
target.lastElementChild.innerHTML,
"This domain is not supported.",
"an error message should be displayed because of the `parent` key"
target.lastElementChild.innerText,
`This domain is not supported. RESET DOMAIN`
);
});
@@ -631,4 +627,58 @@ QUnit.module("Components", (hooks) => {
await editInput(target, ".o_domain_leaf_value_input", `["b"]`);
assert.strictEqual(comp.value, `[("state", "in", ["b"])]`);
});
QUnit.test("domain not supported (mode readonly)", async (assert) => {
await mountComponent(DomainSelector, {
props: {
resModel: "partner",
value: `[`,
readonly: true,
isDebugMode: false,
},
});
assert.containsNone(target, ".o_domain_add_first_node_button");
assert.containsNone(target, ".o_domain_debug_input");
});
QUnit.test("domain not supported (mode readonly + mode debug)", async (assert) => {
await mountComponent(DomainSelector, {
props: {
resModel: "partner",
value: `[`,
readonly: true,
isDebugMode: true,
},
});
assert.containsNone(target, ".o_domain_add_first_node_button");
assert.containsOnce(target, ".o_domain_debug_input");
assert.ok(target.querySelector(".o_domain_debug_input").hasAttribute("readonly"));
});
QUnit.test("domain not supported (mode edit)", async (assert) => {
await mountComponent(DomainSelector, {
props: {
resModel: "partner",
value: `[`,
readonly: false,
isDebugMode: false,
},
});
assert.containsOnce(target, ".o_domain_add_first_node_button");
assert.containsNone(target, ".o_domain_debug_input");
});
QUnit.test("domain not supported (mode edit + mode debug)", async (assert) => {
await mountComponent(DomainSelector, {
props: {
resModel: "partner",
value: `[`,
readonly: false,
isDebugMode: true,
},
});
assert.containsOnce(target, ".o_domain_add_first_node_button");
assert.containsOnce(target, ".o_domain_debug_input");
assert.notOk(target.querySelector(".o_domain_debug_input").hasAttribute("readonly"));
});
});
@@ -119,7 +119,7 @@ QUnit.module("Fields", (hooks) => {
assert.strictEqual(
target.querySelector(".o_edit_mode").textContent,
"This domain is not supported.",
" This domain is not supported. Reset domain",
"The widget should not crash the view, but gracefully admit its failure."
);
}