[IMP] web, *: Add autoresize hooks

This commit adds two autoresize hooks for text inputs and textareas to
make it adapt their size (width for text inputs, height for textareas)
depending on their content.
The commit therefore solves the issue of several form view task titles
being restricted to 1 line while it can be annoying if the title is too
long. The autoresizeTextarea feature was moved from TextField to the
autoresizeTextarea hook and the autoresizeInput feature was moved from
the AutoresizeInput mail component to the autoresizeInput hook.
The TextField component has a new option for disabling linebreaks.

task-3138826

closes odoo/odoo#117355

Related: odoo/enterprise#39493
Signed-off-by: Géry Debongnie <ged@odoo.com>
This commit is contained in:
Julien Carion (juca)
2023-04-25 14:07:15 +02:00
parent 8d50daeefa
commit eaacaf122d
21 changed files with 172 additions and 126 deletions
+1 -1
View File
@@ -49,7 +49,7 @@
<widget name="web_ribbon" title="Lost" bg_color="bg-danger" attrs="{'invisible': ['|', ('probability', '&gt;', 0), ('active', '=', True)]}"/>
<widget name="web_ribbon" title="Won" attrs="{'invisible': [('probability', '&lt;', 100)]}" />
<div class="oe_title">
<h1><field class="text-break" name="name" placeholder="e.g. Product Pricing"/></h1>
<h1><field class="text-break" options="{'line_breaks': False}" widget="text" name="name" placeholder="e.g. Product Pricing"/></h1>
<h2 class="d-flex gap-2 g-0 align-items-end pb-3">
<div attrs="{'invisible': [('type', '=', 'lead')]}">
<label for="expected_revenue" class="oe_edit_only pb-1" />
+1 -1
View File
@@ -35,7 +35,7 @@
<field name="kanban_state" widget="state_selection" class="ms-auto float-end"/>
<div class="oe_title">
<label for="name" string="Event Name"/>
<h1><field class="text-break" name="name" placeholder="e.g. Conference for Architects"/></h1>
<h1><field class="text-break" options="{'line_breaks': False}" widget="text" name="name" placeholder="e.g. Conference for Architects"/></h1>
</div>
<group>
<group>
+1 -1
View File
@@ -14,7 +14,7 @@
<widget name="web_ribbon" title="Archived" bg_color="bg-danger" attrs="{'invisible': [('active', '=', True)]}"/>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" placeholder="e.g. Sales Manager"/></h1>
<h1><field name="name" options="{'line_breaks': False}" widget="text" placeholder="e.g. Sales Manager"/></h1>
</div>
<notebook>
<page string="Recruitment" name="recruitment_page">
@@ -107,7 +107,7 @@
<div class="oe_title pe-0">
<label for="name" class="oe_edit_only"/>
<h1 class="d-flex justify-content-between align-items-center">
<field name="name" placeholder="e.g. Sales Manager 2 year experience"/>
<field name="name" options="{'line_breaks': False}" widget="text" placeholder="e.g. Sales Manager 2 year experience"/>
<field name="kanban_state" widget="state_selection"/>
</h1>
<h2 class="o_row">
@@ -1,7 +1,8 @@
/* @odoo-module */
import { onExternalClick } from "@mail/utils/hooks";
import { Component, useRef, useState, onWillUpdateProps, useEffect } from "@odoo/owl";
import { Component, useRef, useState, onWillUpdateProps } from "@odoo/owl";
import { useAutoresize } from "@web/core/utils/autoresize";
export class AutoresizeInput extends Component {
static template = "mail.AutoresizeInput";
@@ -23,32 +24,13 @@ export class AutoresizeInput extends Component {
value: this.props.value,
});
this.inputRef = useRef("input");
this.maxWidth = undefined;
onWillUpdateProps((nextProps) => {
if (this.props.value !== nextProps.value) {
this.state.value = nextProps.value;
}
});
onExternalClick("input", () => this.onValidate());
useEffect(
() => {
// This mesures the maximum width of the input which can get from the flex layout.
this.inputRef.el.style.width = "100%";
this.maxWidth = this.inputRef.el.clientWidth;
// Minimum width of the input
this.inputRef.el.style.width = "10px";
if (this.state.value === "" && this.props.placeholder !== "") {
this.inputRef.el.style.width = "auto";
return;
}
if (this.inputRef.el.scrollWidth + 5 > this.maxWidth) {
this.inputRef.el.style.width = "100%";
return;
}
this.inputRef.el.style.width = this.inputRef.el.scrollWidth + 5 + "px";
},
() => [this.state.value]
);
useAutoresize(this.inputRef);
}
/**
+1 -1
View File
@@ -44,7 +44,7 @@
<h1>
<div class="d-flex">
<field name="priority" widget="priority" class="me-3"/>
<field class="text-break" name="name" placeholder="e.g. Cheese Burger"/>
<field class="text-break" name="name" options="{'line_breaks': False}" widget="text" placeholder="e.g. Cheese Burger"/>
</div>
</h1>
</div>
@@ -55,7 +55,7 @@
<div class="oe_title">
<h1 class="d-flex flex-row">
<field name="is_favorite" nolabel="1" widget="boolean_favorite" class="me-2"/>
<field name="name" class="o_text_overflow" placeholder="e.g. Office Party"/>
<field name="name" options="{'line_breaks': False}" widget="text" class="o_text_overflow" placeholder="e.g. Office Party"/>
</h1>
</div>
<group>
+1 -1
View File
@@ -313,7 +313,7 @@
<h1 class="d-flex justify-content-between align-items-center">
<div class="d-flex w-100">
<field name="priority" widget="priority_switch" class="me-3"/>
<field name="name" class="o_task_name text-truncate w-100 w-md-75 pe-2" placeholder="Task Title..."/>
<field name="name" options="{'line_breaks': False}" widget="text" class="o_task_name text-truncate w-md-75 w-100 pe-2" placeholder="Task Title..."/>
</div>
<div class="d-flex justify-content-end o_state_container">
<field name="state" widget="project_task_state_selection" class="o_task_state_widget"/>
@@ -181,7 +181,7 @@ registry.category("web_tour.tours").add('sale_timesheet_tour', {
trigger: '[name=sale_line_id] ul.ui-autocomplete > li:first-child > a:not(:has(i.fa))',
content: 'Select the first Sales Order Item in the autocomplete dropdown.',
}, {
trigger: 'h1 > div[name="name"] > input',
trigger: 'h1 > div[name="name"] > div > textarea',
content: 'Set Project name',
run: 'text Project with employee mapping',
}, {
@@ -16,7 +16,7 @@ registry.category("web_tour.tours").add('survey_tour_test_survey_form_triggers',
trigger: ".o-kanban-button-new",
}, {
content: "Set the Survey's title",
trigger: ".o_field_widget[name=title] input",
trigger: ".o_field_widget[name=title] textarea",
run: "text Test survey",
}, {
content: "Add a first question",
+1 -1
View File
@@ -53,7 +53,7 @@
<label for="title" class="oe_edit_only" string="Survey Title" attrs="{'invisible': [('certification', '=', True)]}"/>
<label for="title" class="oe_edit_only" string="Certification Title" attrs="{'invisible': [('certification', '=', False)]}"/>
<h1>
<field name="title" placeholder="e.g. Satisfaction Survey"/>
<field name="title" options="{'line_breaks': False}" widget="text" placeholder="e.g. Satisfaction Survey"/>
</h1>
</div>
<group>
@@ -0,0 +1,85 @@
/** @odoo-module **/
import { useEffect } from "@odoo/owl";
/**
* This is used on text inputs or textareas to automatically resize it based on its
* content each time it is updated. It takes the reference of the element as
* parameter and some options. Do note that it may introduce mild performance issues
* since it will force a reflow of the layout each time the element is updated.
* Do also note that it only works with textareas that are nested as only child
* of some parent div (like in the text_field component).
*
* @param {Ref} ref
*/
export function useAutoresize(ref, options = {}) {
let resize = null;
useEffect(
(el) => {
if (el) {
resize = (el instanceof HTMLInputElement ? resizeInput : resizeTextArea).bind(
null,
el,
options
);
el.addEventListener("input", resize);
return () => {
el.removeEventListener("input", resize);
resize = null;
};
}
},
() => [ref.el]
);
useEffect(() => {
if (resize) {
resize(ref.el, options);
}
});
}
function resizeInput(input) {
// This mesures the maximum width of the input which can get from the flex layout.
input.style.width = "100%";
const maxWidth = input.clientWidth;
// Minimum width of the input
input.style.width = "10px";
if (input.value === "" && input.placeholder !== "") {
input.style.width = "auto";
return;
}
if (input.scrollWidth + 5 > maxWidth) {
input.style.width = "100%";
return;
}
input.style.width = input.scrollWidth + 5 + "px";
}
function resizeTextArea(textarea, options) {
const minimumHeight = options.minimumHeight || 0;
let heightOffset = 0;
const style = window.getComputedStyle(textarea);
if (style.boxSizing === "border-box") {
const paddingHeight = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom);
const borderHeight = parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth);
heightOffset = borderHeight + paddingHeight;
}
const previousStyle = {
borderTopWidth: style.borderTopWidth,
borderBottomWidth: style.borderBottomWidth,
padding: style.padding,
};
Object.assign(textarea.style, {
height: "auto",
borderTopWidth: 0,
borderBottomWidth: 0,
paddingTop: 0,
paddingRight: style.paddingRight,
paddingBottom: 0,
paddingLeft: style.paddingLeft,
});
textarea.style.height = "auto";
const height = Math.max(minimumHeight, textarea.scrollHeight + heightOffset);
Object.assign(textarea.style, previousStyle, { height: `${height}px` });
textarea.parentElement.style.height = `${height}px`;
}
@@ -70,8 +70,9 @@ export const charField = {
supportedTypes: ["char"],
extractProps: ({ attrs, options }) => ({
isPassword: archParseBoolean(attrs.password),
dynamicPlaceholder: options?.dynamic_placeholder || false,
dynamicPlaceholderModelReferenceField: options?.dynamic_placeholder_model_reference_field || "",
dynamicPlaceholder: options.dynamic_placeholder || false,
dynamicPlaceholderModelReferenceField:
options.dynamic_placeholder_model_reference_field || "",
autocomplete: attrs.autocomplete,
placeholder: attrs.placeholder,
}),
@@ -168,7 +168,6 @@ export class Field extends Component {
);
},
};
propsFromNode = this.field.extractProps(fieldInfo, dynamicInfo);
}
}
@@ -14,6 +14,7 @@ import { useComponent, useEffect, useRef } from "@odoo/owl";
* @param {() => string} getValue a function that returns the value to write in
* the input, if the user isn't currently editing it
* @param {string} [refName="input"] the ref of the input/textarea
* @param {boolean} preventLineBreaks Prevent line breaks in input when set
*/
export function useInputField(params) {
const inputRef = params.ref || useRef(params.refName || "input");
@@ -84,6 +85,9 @@ export function useInputField(params) {
if (["enter", "tab", "shift+tab"].includes(hotkey)) {
commitChanges(false);
}
if (params.preventLineBreaks && ["enter", "shift+enter"].includes(hotkey)) {
ev.preventDefault();
}
}
useEffect(
@@ -2,6 +2,7 @@
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { useAutoresize } from "@web/core/utils/autoresize";
import { useSpellCheck } from "@web/core/utils/hooks";
import { useDynamicPlaceholder } from "../dynamic_placeholder_hook";
import { useInputField } from "../input_field_hook";
@@ -18,12 +19,14 @@ export class TextField extends Component {
};
static props = {
...standardFieldProps,
lineBreaks: { type: Boolean, optional: true },
placeholder: { type: String, optional: true },
dynamicPlaceholder: { type: Boolean, optional: true },
dynamicPlaceholderModelReferenceField: { type: String, optional: true },
rowCount: { type: Number, optional: true },
};
static defaultProps = {
lineBreaks: true,
dynamicPlaceholder: false,
rowCount: 2,
};
@@ -41,55 +44,23 @@ export class TextField extends Component {
useInputField({
getValue: () => this.props.record.data[this.props.name] || "",
refName: "textarea",
preventLineBreaks: !this.props.lineBreaks,
});
useSpellCheck({ refName: "textarea" });
useEffect(() => {
if (!this.props.readonly) {
this.resize();
}
});
if (!this.props.readonly) {
useAutoresize(this.textareaRef, { minimumHeight: this.minimumHeight });
}
}
get isTranslatable() {
return this.props.record.fields[this.props.name].translate;
}
get minimumHeight() {
return 50;
return this.props.lineBreaks ? 50 : 0;
}
get rowCount() {
return this.props.rowCount;
}
resize() {
const textarea = this.textareaRef.el;
let heightOffset = 0;
const style = window.getComputedStyle(textarea);
if (style.boxSizing === "border-box") {
const paddingHeight = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom);
const borderHeight =
parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth);
heightOffset = borderHeight + paddingHeight;
}
const previousStyle = {
borderTopWidth: style.borderTopWidth,
borderBottomWidth: style.borderBottomWidth,
padding: style.padding,
};
Object.assign(textarea.style, {
height: "auto",
borderTopWidth: 0,
borderBottomWidth: 0,
padding: 0,
});
textarea.style.height = "auto";
const height = Math.max(this.minimumHeight, textarea.scrollHeight + heightOffset);
Object.assign(textarea.style, previousStyle, { height: `${height}px` });
this.divRef.el.style.height = `${height}px`;
}
onInput() {
this.resize();
return this.props.lineBreaks ? this.props.rowCount : 1;
}
}
@@ -100,8 +71,10 @@ export const textField = {
extractProps: ({ attrs, options }) => ({
placeholder: attrs.placeholder,
dynamicPlaceholder: options?.dynamic_placeholder || false,
dynamicPlaceholderModelReferenceField: options?.dynamic_placeholder_model_reference_field || "",
dynamicPlaceholderModelReferenceField:
options?.dynamic_placeholder_model_reference_field || "",
rowCount: attrs.rows && parseInteger(attrs.rows),
lineBreaks: options?.line_breaks !== undefined ? Boolean(options.line_breaks) : true,
}),
};
@@ -13,7 +13,6 @@
t-att-id="props.id"
t-att-placeholder="props.placeholder"
t-att-rows="rowCount"
t-on-input="onInput"
t-ref="textarea"
/>
<t t-if="isTranslatable">
@@ -407,22 +407,24 @@ QUnit.module("Fields", (hooks) => {
);
});
QUnit.test("translation dialog should close if field is not there anymore", async function (assert) {
// In this test, we simulate the case where the field is removed from the view
// this can happend for example if the user click the back button of the browser.
serverData.models.partner.fields.foo.translate = true;
serviceRegistry.add("localization", makeFakeLocalizationService({ multiLang: true }), {
force: true,
});
patchWithCleanup(session.user_context, {
lang: "en_US",
});
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
QUnit.test(
"translation dialog should close if field is not there anymore",
async function (assert) {
// In this test, we simulate the case where the field is removed from the view
// this can happend for example if the user click the back button of the browser.
serverData.models.partner.fields.foo.translate = true;
serviceRegistry.add("localization", makeFakeLocalizationService({ multiLang: true }), {
force: true,
});
patchWithCleanup(session.user_context, {
lang: "en_US",
});
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<sheet>
<group>
@@ -431,36 +433,37 @@ QUnit.module("Fields", (hooks) => {
</group>
</sheet>
</form>`,
mockRPC(route, { args, method, model }) {
if (route === "/web/dataset/call_kw/res.lang/get_installed") {
return Promise.resolve([
["en_US", "English"],
["fr_BE", "French (Belgium)"],
["es_ES", "Spanish"],
]);
}
if (route === "/web/dataset/call_kw/partner/get_field_translations") {
return Promise.resolve([
[
{ lang: "en_US", source: "yop", value: "yop" },
{ lang: "fr_BE", source: "yop", value: "valeur français" },
{ lang: "es_ES", source: "yop", value: "yop español" },
],
{ translation_type: "char", translation_show_source: false },
]);
}
},
});
mockRPC(route, { args, method, model }) {
if (route === "/web/dataset/call_kw/res.lang/get_installed") {
return Promise.resolve([
["en_US", "English"],
["fr_BE", "French (Belgium)"],
["es_ES", "Spanish"],
]);
}
if (route === "/web/dataset/call_kw/partner/get_field_translations") {
return Promise.resolve([
[
{ lang: "en_US", source: "yop", value: "yop" },
{ lang: "fr_BE", source: "yop", value: "valeur français" },
{ lang: "es_ES", source: "yop", value: "yop español" },
],
{ translation_type: "char", translation_show_source: false },
]);
}
},
});
assert.hasClass(target.querySelector("[name=foo] input"), "o_field_translate");
assert.hasClass(target.querySelector("[name=foo] input"), "o_field_translate");
await click(target, ".o_field_char .btn.o_field_translate");
assert.containsOnce(target, ".modal", "a translate modal should be visible");
await editInput(target, ".o_field_widget[name=int_field] input", "9");
await nextTick();
assert.containsNone(target, "[name=foo] input", "the field foo should be invisible");
assert.containsNone(target, ".modal", "a translate modal should not be visible");
});
await click(target, ".o_field_char .btn.o_field_translate");
assert.containsOnce(target, ".modal", "a translate modal should be visible");
await editInput(target, ".o_field_widget[name=int_field] input", "9");
await nextTick();
assert.containsNone(target, "[name=foo] input", "the field foo should be invisible");
assert.containsNone(target, ".modal", "a translate modal should not be visible");
}
);
QUnit.test("html field translatable", async function (assert) {
assert.expect(5);
@@ -63,7 +63,7 @@
<field name="image_1920" widget="image" class="oe_avatar" options="{'preview_image': 'image_128'}"/>
<div class="oe_title">
<label for="name" string="Course Title"/>
<h1><field name="name" default_focus="1" placeholder='e.g. "Computer Science for kids"'/></h1>
<h1><field name="name" options="{'line_breaks': False}" widget="text" default_focus="1" placeholder='e.g. "Computer Science for kids"'/></h1>
</div>
<div>
<field name="active" invisible="1"/>
+2 -2
View File
@@ -185,8 +185,8 @@
<field name="country_code" invisible="1"/>
<field name="company_type" widget="radio" options="{'horizontal': true}"/>
<h1>
<field id="company" class="text-break" name="name" default_focus="1" placeholder="e.g. Lumber Inc" attrs="{'required' : [('type', '=', 'contact')], 'invisible': [('is_company','=', False)]}"/>
<field id="individual" class="text-break" name="name" default_focus="1" placeholder="e.g. Brandom Freeman" attrs="{'required' : [('type', '=', 'contact')], 'invisible': [('is_company','=', True)]}"/>
<field id="company" options="{'line_breaks': False}" widget="text" class="text-break" name="name" default_focus="1" placeholder="e.g. Lumber Inc" attrs="{'required' : [('type', '=', 'contact')], 'invisible': [('is_company','=', False)]}"/>
<field id="individual" options="{'line_breaks': False}" widget="text" class="text-break" name="name" default_focus="1" placeholder="e.g. Brandom Freeman" attrs="{'required' : [('type', '=', 'contact')], 'invisible': [('is_company','=', True)]}"/>
</h1>
<div class="o_row">
<field name="parent_id"
@@ -28,7 +28,7 @@ stepUtils.openBurgerMenu("li.breadcrumb-item.active:contains('Quotations')"),
content: _t("Let's create your first product."),
position: 'bottom',
}, {
trigger: '.o_field_widget[name=name] input',
trigger: '.o_field_widget[name=name] textarea',
extra_trigger: '.o_form_sheet',
content: _t("Let's enter the name."),
position: 'left',
@@ -117,7 +117,7 @@ stepUtils.autoExpandMoreButtons('.o_form_saved'),
position: "left",
}, {
mobile: true,
trigger: '.modal-body .o_form_editable .o_field_widget[name="name"] input',
trigger: '.modal-body .o_form_editable .o_field_widget[name="name"] textarea',
content: _t("Select a product, or create a new one on the fly."),
position: "right",
run: "text the_flow.component1",
@@ -243,7 +243,7 @@ stepUtils.autoExpandMoreButtons('.o_form_saved'),
position: "left",
}, {
mobile: true,
trigger: ".modal-body .o_form_editable .o_field_widget[name=name] input",
trigger: ".modal-body .o_form_editable .o_field_widget[name=name] textarea",
content: _t("Select a product, or create a new one on the fly."),
position: "right",
run: "text the_flow.component2",
@@ -348,7 +348,7 @@ stepUtils.autoExpandMoreButtons('.o_form_saved'),
content: _t("Let's create your second product."),
position: 'bottom',
}, {
trigger: '.o_field_widget[name=name] input',
trigger: '.o_field_widget[name=name] textarea',
extra_trigger: '.o_form_sheet',
content: _t("Let's enter the name."),
position: 'left',
@@ -400,7 +400,7 @@ stepUtils.autoExpandMoreButtons('.o_form_saved'),
position: "left",
}, {
mobile: true,
trigger: '.o_field_widget[name=name] input',
trigger: '.o_field_widget[name=name] textarea',
extra_trigger: ".modal:not(.o_inactive_modal) .modal-title:contains('Project')",
content: _t('Let\'s enter the name.'),
position: 'left',