[IMP] web: improve setting search

Before this commit, the setting search was done exclusively in some
selected texts. These texts needed to be on some specific selectors
(field, label, span.o_form_label and div.text-muted).

Now, all text on a setting are searchable (including the text in
buttons). This commit also re-structure the setting compilers file to
remove the functions outside the class, this is done to standardize the
compilers (setting, form and view).

closes odoo/odoo#107226

Signed-off-by: Aaron Bohy (aab) <aab@odoo.com>
This commit is contained in:
Jorge Pinna Puissant
2022-12-08 08:48:46 +01:00
parent 4f0c264ca2
commit ebbc63dbff
2 changed files with 257 additions and 233 deletions
@@ -3,192 +3,175 @@
import { append, createElement, getTag } from "@web/core/utils/xml";
import { FormCompiler } from "@web/views/form/form_compiler";
import { toStringExpression } from "@web/views/utils";
function compileApp(el, params) {
if (el.getAttribute("notApp") === "1") {
return;
}
const module = {
key: el.getAttribute("name"),
string: el.getAttribute("string"),
imgurl:
el.getAttribute("logo") ||
"/" + el.getAttribute("name") + "/static/description/icon.png",
};
params.modules.push(module);
const settingsApp = createElement("SettingsApp", {
key: toStringExpression(module.key),
string: toStringExpression(module.string || ""),
imgurl: toStringExpression(module.imgurl),
selectedTab: "settings.selectedTab",
});
for (const child of el.children) {
append(settingsApp, this.compileNode(child, params));
}
return settingsApp;
}
function compileBlock(el, params) {
const settingsContainer = createElement("SettingsBlock", {
title: toStringExpression(el.getAttribute("title") || ""),
tip: toStringExpression(el.getAttribute("help") || ""),
});
for (const child of el.children) {
append(settingsContainer, this.compileNode(child, params));
}
return settingsContainer;
}
function compileSetting(el, params) {
const componentName = el.getAttribute("type") === "header" ? "SettingHeader" : "Setting";
const setting = createElement(componentName, {
title: toStringExpression(el.getAttribute("title") || ""),
help: toStringExpression(el.getAttribute("help") || ""),
companyDependent: el.getAttribute("company_dependent") === "1" || "false",
documentation: toStringExpression(el.getAttribute("documentation") || ""),
record: `this.props.record`,
});
let string = toStringExpression(el.getAttribute("string") || "");
let addLabel = true;
params.labels = [];
Array.from(el.children).forEach((child, index) => {
if (getTag(child, true) === "field" && index === 0) {
const fieldSlot = createElement("t", { "t-set-slot": "fieldSlot" });
const field = this.compileNode(child, params);
if (field) {
append(fieldSlot, field);
setting.setAttribute("fieldInfo", field.getAttribute("fieldInfo"));
addLabel = child.hasAttribute("nolabel")
? child.getAttribute("nolabel") !== "1"
: true;
const fieldName = child.getAttribute("name");
string = child.hasAttribute("string")
? toStringExpression(child.getAttribute("string"))
: string;
setting.setAttribute("fieldName", toStringExpression(fieldName));
setting.setAttribute(
"fieldId",
toStringExpression(child.getAttribute("field_id") || fieldName)
);
}
append(setting, fieldSlot);
} else {
append(setting, this.compileNode(child, params));
}
});
setting.setAttribute("string", string);
setting.setAttribute("addLabel", addLabel);
setting.setAttribute("labels", JSON.stringify(params.labels));
return setting;
}
function compileField(el, params) {
const res = this.compileField(el, params);
if (el.hasAttribute("widget")) {
const label = params.getFieldExpr(el.getAttribute("name"), el.getAttribute("widget"));
if (label) {
params.labels.push(label);
}
}
return res;
}
const labelsWeak = new WeakMap();
function compileLabel(el, params) {
const res = this.compileLabel(el, params);
// It the node is a FormLabel component node, the label is
// localized *after* the field.
// We don't know yet if the label refers to a field or not.
if (res.textContent && res.tagName !== "FormLabel") {
params.labels.push(res.textContent.trim());
labelsWeak.set(res, { textContent: res.textContent });
highlightElement(res);
}
return res;
}
function compileGenericLabel(el, params) {
const res = this.compileGenericNode(el, params);
if (res.textContent) {
params.labels.push(res.textContent.trim());
highlightElement(res);
}
return res;
}
function highlightElement(el) {
for (const child of el.childNodes) {
if (child.nodeType === Node.TEXT_NODE) {
if (child.textContent.trim()) {
const highlight = createElement("HighlightText");
highlight.setAttribute("originalText", `\`${child.textContent}\``);
el.replaceChild(highlight, child);
}
} else if (child.childNodes.length) {
highlightElement(child);
}
}
}
function compileForm(el, params) {
const settingsPage = createElement("SettingsPage");
settingsPage.setAttribute("slots", "{NoContentHelper:this.props.slots.NoContentHelper}");
settingsPage.setAttribute("initialTab", "this.props.initialApp");
settingsPage.setAttribute("t-slot-scope", "settings");
//props
params.modules = [];
const res = this.compileForm(...arguments);
res.classList.remove("o_form_nosheet");
settingsPage.setAttribute("modules", JSON.stringify(params.modules));
for (const child of res.childNodes) {
append(settingsPage, this.compileNode(child, params));
}
while (res.lastChild) {
res.removeChild(res.lastChild);
}
append(res, settingsPage);
return res;
}
import { isTextNode } from "@web/views/view_compiler";
export class SettingsFormCompiler extends FormCompiler {
setup() {
super.setup();
this.compilers.unshift(
{ selector: "form", fn: compileForm },
{ selector: "app", fn: compileApp },
{ selector: "block", fn: compileBlock },
{ selector: "setting", fn: compileSetting },
// search terms and highlight :
{ selector: "label", fn: compileLabel, doNotCopyAttributes: true },
{ selector: "span.o_form_label", fn: compileGenericLabel },
{ selector: "div.text-muted", fn: compileGenericLabel },
{ selector: "field", fn: compileField }
this.compilers.push(
{ selector: "app", fn: this.compileApp },
{ selector: "block", fn: this.compileBlock },
{ selector: "setting", fn: this.compileSetting }
);
}
createLabelFromField(fieldId, fieldName, fieldString, label, params) {
const labelweak = labelsWeak.get(label);
if (labelweak) {
// Undo what we've done when we where not sure whether this label was attached to a field
// Now, we now it is.
label.textContent = labelweak.textContent;
}
const res = super.createLabelFromField(fieldId, fieldName, fieldString, label, params);
if (labelweak || label.hasAttribute("data-no-label")) {
// the work of pushing the label in the search structure is already done
return res;
}
let labelText = label.textContent || fieldString;
labelText = labelText ? labelText : params.record.fields[fieldName].string;
params.labels.push(labelText);
compileForm(el, params) {
const settingsPage = createElement("SettingsPage");
settingsPage.setAttribute("slots", "{NoContentHelper:this.props.slots.NoContentHelper}");
settingsPage.setAttribute("initialTab", "this.props.initialApp");
settingsPage.setAttribute("t-slot-scope", "settings");
//props
params.modules = [];
const res = super.compileForm(...arguments);
res.classList.remove("o_form_nosheet");
settingsPage.setAttribute("modules", JSON.stringify(params.modules));
// Move the compiled content of the form inside the settingsPage
while (res.firstChild) {
append(settingsPage, res.firstChild);
}
append(res, settingsPage);
return res;
}
compileApp(el, params) {
if (el.getAttribute("notApp") === "1") {
//An app noted with notApp="1" is not rendered.
//This hack is used when a technical module defines settings, and we don't want to render
//the settings until the corresponding app is not installed.
// For example, when installing the module website_sale, the module sale is also installed,
// but we don't want to render its settings (notApp="1").
// On the contrary, when sale_management is installed, the module sale is also installed
// but in this case we want to see its settings (notApp="0").
return;
}
const module = {
key: el.getAttribute("name"),
string: el.getAttribute("string"),
imgurl:
el.getAttribute("logo") ||
"/" + el.getAttribute("name") + "/static/description/icon.png",
};
params.modules.push(module);
const settingsApp = createElement("SettingsApp", {
key: toStringExpression(module.key),
string: toStringExpression(module.string || ""),
imgurl: toStringExpression(module.imgurl),
selectedTab: "settings.selectedTab",
});
for (const child of el.children) {
append(settingsApp, this.compileNode(child, params));
}
return settingsApp;
}
compileBlock(el, params) {
const settingsContainer = createElement("SettingsBlock", {
title: toStringExpression(el.getAttribute("title") || ""),
tip: toStringExpression(el.getAttribute("help") || ""),
});
for (const child of el.children) {
append(settingsContainer, this.compileNode(child, params));
}
return settingsContainer;
}
compileSetting(el, params) {
const componentName = el.getAttribute("type") === "header" ? "SettingHeader" : "Setting";
const setting = createElement(componentName, {
title: toStringExpression(el.getAttribute("title") || ""),
help: toStringExpression(el.getAttribute("help") || ""),
companyDependent: el.getAttribute("company_dependent") === "1" || "false",
documentation: toStringExpression(el.getAttribute("documentation") || ""),
record: `this.props.record`,
});
let string = toStringExpression(el.getAttribute("string") || "");
let addLabel = true;
params.labels = [];
Array.from(el.children).forEach((child, index) => {
if (getTag(child, true) === "field" && index === 0) {
const fieldSlot = createElement("t", { "t-set-slot": "fieldSlot" });
const field = this.compileNode(child, params);
if (field) {
append(fieldSlot, field);
setting.setAttribute("fieldInfo", field.getAttribute("fieldInfo"));
addLabel = child.hasAttribute("nolabel")
? child.getAttribute("nolabel") !== "1"
: true;
const fieldName = child.getAttribute("name");
string = child.hasAttribute("string")
? toStringExpression(child.getAttribute("string"))
: string;
setting.setAttribute("fieldName", toStringExpression(fieldName));
setting.setAttribute(
"fieldId",
toStringExpression(child.getAttribute("field_id") || fieldName)
);
}
append(setting, fieldSlot);
} else {
append(setting, this.compileNode(child, params));
}
});
setting.setAttribute("string", string);
setting.setAttribute("addLabel", addLabel);
setting.setAttribute("labels", JSON.stringify(params.labels));
delete params.labels;
return setting;
}
compileField(el, params) {
const res = super.compileField(el, params);
if (params.labels && el.hasAttribute("widget")) {
const label = params.getFieldExpr(el.getAttribute("name"), el.getAttribute("widget"));
if (label) {
params.labels.push(label);
}
}
return res;
}
compileNode(node, params, evalInvisible) {
if (isTextNode(node)) {
if (params.labels && node.textContent.trim()) {
params.labels.push(node.textContent.trim());
return createElement("HighlightText", {
originalText: toStringExpression(node.textContent),
});
}
}
return super.compileNode(node, params, evalInvisible);
}
createLabelFromField(fieldId, fieldName, fieldString, label, params) {
const res = super.createLabelFromField(fieldId, fieldName, fieldString, label, params);
if (res.hasAttribute("string") && params.labels) {
params.labels.push(res.getAttribute("string"));
}
return res;
}
compileButton(el, params) {
const res = super.compileButton(el, params);
if (res.hasAttribute("string") && params.labels && res.children.length === 0) {
params.labels.push(res.getAttribute("string"));
const contentSlot = createElement("t");
contentSlot.setAttribute("t-set-slot", "contents");
const content = createElement("HighlightText", {
originalText: res.getAttribute("string"),
});
append(contentSlot, content);
append(res, contentSlot);
}
return res;
}
}
@@ -77,9 +77,13 @@ QUnit.module("SettingsFormView", (hooks) => {
<block title="Title of group Bar">
<setting help="this is bar" documentation="/applications/technical/web/settings/this_is_a_test.html">
<field name="bar"/>
<button name="buttonName" icon="fa-arrow-right" type="action" string="Manage Users" class="btn-link"/>
</setting>
<setting string="This is Big BAR" help="this is big bar">
<field name="bar"/>
<setting>
<label string="Big BAZ" for="baz"/>
<div class="text-muted">this is a baz</div>
<field name="baz"/>
<label>label with content</label>
</setting>
</block>
<block title="Title of group Foo">
@@ -113,11 +117,11 @@ QUnit.module("SettingsFormView", (hooks) => {
[...target.querySelectorAll(".settings .o_settings_container .o_form_label")].map(
(x) => x.textContent
),
["Bar", "This is Big BAR", "Foo", "Personalize setting"]
["Bar", "Big BAZ", "Foo", "Personalize setting"]
);
assert.deepEqual(
[...target.querySelectorAll(".settings .text-muted")].map((x) => x.textContent),
["this is bar", "this is big bar", "this is foo", "this is full personalize setting"]
["this is bar", "this is a baz", "this is foo", "this is full personalize setting"]
);
assert.deepEqual(
[...target.querySelectorAll(".settings h2:not(.d-none)")].map((x) => x.textContent),
@@ -161,7 +165,7 @@ QUnit.module("SettingsFormView", (hooks) => {
[...target.querySelectorAll(".o_settings_container .o_setting_box .o_form_label")].map(
(x) => x.textContent
),
["Bar", "This is Big BAR"],
["Bar", "Big BAZ"],
"Foo is not shown"
);
@@ -181,8 +185,8 @@ QUnit.module("SettingsFormView", (hooks) => {
[...target.querySelectorAll(".o_settings_container .o_setting_box .o_form_label")].map(
(x) => x.textContent
),
["This is Big BAR"],
"Only 'Big Bar' is shown"
["Big BAZ"],
"Only 'Big Baz' is shown"
);
assert.deepEqual(
[...target.querySelectorAll(".settings h2:not(.d-none)")].map((x) => x.textContent),
@@ -194,13 +198,46 @@ QUnit.module("SettingsFormView", (hooks) => {
".app_settings_block:not(.d-none) .app_settings_header .o_setting_box"
);
await editSearch(target, "Manage Us");
await execTimeouts();
assert.strictEqual(
target.querySelector(".highlighter").textContent,
"Manage Us",
"Manage Us word highlighted"
);
assert.deepEqual(
[...target.querySelectorAll(".o_settings_container .o_setting_box .o_form_label")].map(
(x) => x.textContent
),
["Bar"],
"Foo is not shown"
);
assert.containsOnce(
target,
".app_settings_block:not(.d-none) .app_settings_header .o_setting_box"
);
await editSearch(target, "group Bar");
await execTimeouts();
assert.deepEqual(
[...target.querySelectorAll(".o_settings_container .o_setting_box .o_form_label")].map(
(x) => x.textContent
),
["Bar", "This is Big BAR"],
["Bar", "Big BAZ"],
"When searching a title, all group is shown"
);
assert.containsOnce(
target,
".app_settings_block:not(.d-none) .app_settings_header .o_setting_box"
);
await editSearch(target, "different");
await execTimeouts();
assert.deepEqual(
[...target.querySelectorAll(".o_settings_container .o_setting_box .o_form_label")].map(
(x) => x.textContent
),
["Personalize setting"],
"When searching a title, all group is shown"
);
assert.containsOnce(
@@ -418,11 +455,11 @@ QUnit.module("SettingsFormView", (hooks) => {
serverData,
arch: `
<form string="Settings" class="oe_form_configuration o_base_settings" js_class="base_settings">
<app string="Base Setting" name="base-setting">
<setting>
<field name="bar"/>Make Changes
</setting>
</app>
<app string="Base Setting" name="base-setting">
<setting>
<field name="bar"/>Make Changes
</setting>
</app>
</form>`,
mockRPC(route, { args, method, model }) {
if (method === "create" && model === "res.config.settings") {
@@ -454,9 +491,9 @@ QUnit.module("SettingsFormView", (hooks) => {
<app string="CRM" name="crm">
<block>
<setting>
<label for="foo" string="Label Before" class="a"/>
<field name="foo" class="b"/>
<label for="foo" string="Label After" class="c"/>
<label for="foo" string="Label Before" class="a"/>
<field name="foo" class="b"/>
<label for="foo" string="Label After" class="c"/>
</setting>
</block>
</app>
@@ -482,13 +519,13 @@ QUnit.module("SettingsFormView", (hooks) => {
serverData.views = {
"res.config.settings,1,form": `
<form string="Settings" js_class="base_settings">
<app string="CRM" name="crm">
<block>
<setting help="this is foo">
<field name="foo"/>
</setting>
</block>
</app>
<app string="CRM" name="crm">
<block>
<setting help="this is foo">
<field name="foo"/>
</setting>
</block>
</app>
</form>`,
"task,2,list": `
<tree>
@@ -648,14 +685,14 @@ QUnit.module("SettingsFormView", (hooks) => {
serverData.views = {
"res.config.settings,1,form": `
<form string="Settings" js_class="base_settings">
<app string="CRM" name="crm">
<block>
<setting string="Foo" help="this is foo">
<field name="foo"/>
</setting>
</block>
<button name="4" string="Execute action" type="action"/>
</app>
<app string="CRM" name="crm">
<block>
<setting string="Foo" help="this is foo">
<field name="foo"/>
</setting>
</block>
<button name="4" string="Execute action" type="action"/>
</app>
</form>`,
"task,2,list": '<tree><field name="display_name"/></tree>',
"res.config.settings,false,search": "<search></search>",
@@ -1084,14 +1121,14 @@ QUnit.module("SettingsFormView", (hooks) => {
serverData.views = {
"res.config.settings,1,form": `
<form string="Settings" js_class="base_settings">
<app string="CRM" name="crm">
<block>
<setting string="Foo" help="this is foo">
<field name="foo"/>
</setting>
<button name="4" string="Execute action" type="action"/>
</block>
</app>
<app string="CRM" name="crm">
<block>
<setting string="Foo" help="this is foo">
<field name="foo"/>
</setting>
<button name="4" string="Execute action" type="action"/>
</block>
</app>
</form>
`,
"res.config.settings,false,search": "<search></search>",
@@ -1278,10 +1315,12 @@ QUnit.module("SettingsFormView", (hooks) => {
serverData,
arch: `
<form js_class="base_settings">
<app string="CRM" name="crm">
<label string="My&quot; little &apos; Label" for="display_name" class="highhopes"/>
<field name="display_name" />
</app>
<app string="CRM" name="crm">
<setting>
<label string="My&quot; little &apos; Label" for="display_name" class="highhopes"/>
<field name="display_name" />
</setting>
</app>
</form>`,
});
@@ -1293,8 +1332,10 @@ QUnit.module("SettingsFormView", (hooks) => {
const expectedCompiled = `
<SettingsPage slots="{NoContentHelper:this.props.slots.NoContentHelper}" initialTab="this.props.initialApp" t-slot-scope="settings" modules="[{&quot;key&quot;:&quot;crm&quot;,&quot;string&quot;:&quot;CRM&quot;,&quot;imgurl&quot;:&quot;/crm/static/description/icon.png&quot;}]">
<SettingsApp key="\`crm\`" string="\`CRM\`" imgurl="\`/crm/static/description/icon.png\`" selectedTab="settings.selectedTab">
<FormLabel id="'display_name'" fieldName="'display_name'" record="this.props.record" fieldInfo="this.props.archInfo.fieldNodes['display_name']" className="&quot;highhopes&quot;" string="\`My&quot; little ' Label\`"/>
<Field id="'display_name'" name="'display_name'" record="this.props.record" fieldInfo="this.props.archInfo.fieldNodes['display_name']"/>
<Setting title="\`\`" help="\`\`" companyDependent="false" documentation="\`\`" record="this.props.record" string="\`\`" addLabel="true" labels="[&quot;\`My\\&quot; little ' Label\`&quot;]">
<FormLabel id="'display_name'" fieldName="'display_name'" record="this.props.record" fieldInfo="this.props.archInfo.fieldNodes['display_name']" className="&quot;highhopes&quot;" string="\`My&quot; little ' Label\`"/>
<Field id="'display_name'" name="'display_name'" record="this.props.record" fieldInfo="this.props.archInfo.fieldNodes['display_name']"/>
</Setting>
</SettingsApp>
</SettingsPage>`;
assert.areEquivalent(compiled.firstChild.innerHTML, expectedCompiled);
@@ -1316,15 +1357,15 @@ QUnit.module("SettingsFormView", (hooks) => {
serverData,
arch: `
<form string="Settings" class="oe_form_configuration o_base_settings" js_class="base_settings">
<app string="CRM" name="crm">
<block title="Title of group Bar">
<setting>
<field name="bar"/>
<div class="text-muted">this is Baz value: <field name="baz" readonly="1"/> and this is the after text</div>
</setting>
</block>
</app>
</form>`,
<app string="CRM" name="crm">
<block title="Title of group Bar">
<setting>
<field name="bar"/>
<div class="text-muted">this is Baz value: <field name="baz" readonly="1"/> and this is the after text</div>
</setting>
</block>
</app>
</form>`,
});
assert.strictEqual(