-
+
diff --git a/addons/sale_timesheet/static/tests/tours/sale_timesheet_tour.js b/addons/sale_timesheet/static/tests/tours/sale_timesheet_tour.js
index e2248f2edcb..d6521b33e9a 100644
--- a/addons/sale_timesheet/static/tests/tours/sale_timesheet_tour.js
+++ b/addons/sale_timesheet/static/tests/tours/sale_timesheet_tour.js
@@ -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',
}, {
diff --git a/addons/survey/static/tests/tours/survey_form.js b/addons/survey/static/tests/tours/survey_form.js
index 543ce676c67..3d815570038 100644
--- a/addons/survey/static/tests/tours/survey_form.js
+++ b/addons/survey/static/tests/tours/survey_form.js
@@ -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",
diff --git a/addons/survey/views/survey_survey_views.xml b/addons/survey/views/survey_survey_views.xml
index 8e5be09daca..95886458c3b 100644
--- a/addons/survey/views/survey_survey_views.xml
+++ b/addons/survey/views/survey_survey_views.xml
@@ -53,7 +53,7 @@
-
+
diff --git a/addons/web/static/src/core/utils/autoresize.js b/addons/web/static/src/core/utils/autoresize.js
new file mode 100644
index 00000000000..d6a084b9ead
--- /dev/null
+++ b/addons/web/static/src/core/utils/autoresize.js
@@ -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`;
+}
diff --git a/addons/web/static/src/views/fields/char/char_field.js b/addons/web/static/src/views/fields/char/char_field.js
index 86a90d7cb10..daa3bb3b30f 100644
--- a/addons/web/static/src/views/fields/char/char_field.js
+++ b/addons/web/static/src/views/fields/char/char_field.js
@@ -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,
}),
diff --git a/addons/web/static/src/views/fields/field.js b/addons/web/static/src/views/fields/field.js
index 4fe6b313030..1f90a9c040c 100644
--- a/addons/web/static/src/views/fields/field.js
+++ b/addons/web/static/src/views/fields/field.js
@@ -168,7 +168,6 @@ export class Field extends Component {
);
},
};
-
propsFromNode = this.field.extractProps(fieldInfo, dynamicInfo);
}
}
diff --git a/addons/web/static/src/views/fields/input_field_hook.js b/addons/web/static/src/views/fields/input_field_hook.js
index 8a4ff114b73..0660f24a3d8 100644
--- a/addons/web/static/src/views/fields/input_field_hook.js
+++ b/addons/web/static/src/views/fields/input_field_hook.js
@@ -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(
diff --git a/addons/web/static/src/views/fields/text/text_field.js b/addons/web/static/src/views/fields/text/text_field.js
index 37fdd1bda5e..26b2c664604 100644
--- a/addons/web/static/src/views/fields/text/text_field.js
+++ b/addons/web/static/src/views/fields/text/text_field.js
@@ -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,
}),
};
diff --git a/addons/web/static/src/views/fields/text/text_field.xml b/addons/web/static/src/views/fields/text/text_field.xml
index 7cf86ef9b69..1598e2dc331 100644
--- a/addons/web/static/src/views/fields/text/text_field.xml
+++ b/addons/web/static/src/views/fields/text/text_field.xml
@@ -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"
/>
diff --git a/addons/web/static/tests/views/fields/char_field_tests.js b/addons/web/static/tests/views/fields/char_field_tests.js
index e2399b2de67..61cf4f4b3a4 100644
--- a/addons/web/static/tests/views/fields/char_field_tests.js
+++ b/addons/web/static/tests/views/fields/char_field_tests.js
@@ -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: `
`,
- 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);
diff --git a/addons/website_slides/views/slide_channel_views.xml b/addons/website_slides/views/slide_channel_views.xml
index 5dff9d901da..ab9df39b499 100644
--- a/addons/website_slides/views/slide_channel_views.xml
+++ b/addons/website_slides/views/slide_channel_views.xml
@@ -63,7 +63,7 @@
-
+
diff --git a/odoo/addons/base/views/res_partner_views.xml b/odoo/addons/base/views/res_partner_views.xml
index dab87207646..702c34262c2 100644
--- a/odoo/addons/base/views/res_partner_views.xml
+++ b/odoo/addons/base/views/res_partner_views.xml
@@ -185,8 +185,8 @@
-
-
+
+