From 89c2a3978e5f46b003789f8a8a06d69162f4a404 Mon Sep 17 00:00:00 2001 From: Jorge Pinna Puissant Date: Wed, 1 Feb 2023 07:56:11 +0000 Subject: [PATCH] [FIX] web: show status indicator buttons on editing field Before this commit, when editing a form view, when editing a input field, the status indicator buttons (the save and discard buttons) didn't show up until a click outside the field was done (change event). Now, the status indicator buttons shown as soon as the input field is edited (input event). task-id 3147130 closes odoo/odoo#111387 X-original-commit: 64aa9a6afdc8eb27711f6deb5d6ee3e79e6276b2 Signed-off-by: Aaron Bohy (aab) --- .../static/src/core/datepicker/datepicker.js | 5 ++ .../static/src/core/datepicker/datepicker.xml | 1 + .../src/views/fields/date/date_field.js | 14 +++ .../src/views/fields/date/date_field.xml | 2 + .../views/fields/datetime/datetime_field.js | 14 +++ .../views/fields/datetime/datetime_field.xml | 2 + addons/web/static/src/views/fields/field.js | 2 +- .../static/src/views/form/form_compiler.js | 1 + .../static/src/views/form/form_controller.js | 5 ++ .../static/src/views/form/form_controller.xml | 4 +- .../static/src/views/form/form_renderer.js | 1 + .../form_status_indicator.js | 3 +- .../tests/views/form/form_compiler_tests.js | 19 ++-- .../tests/views/form/form_view_tests.js | 86 ++++++++++++++++--- .../settings_form_view_tests.js | 4 +- 15 files changed, 135 insertions(+), 28 deletions(-) diff --git a/addons/web/static/src/core/datepicker/datepicker.js b/addons/web/static/src/core/datepicker/datepicker.js index 2693dc224de..cb621febd4f 100644 --- a/addons/web/static/src/core/datepicker/datepicker.js +++ b/addons/web/static/src/core/datepicker/datepicker.js @@ -166,6 +166,7 @@ export class DatePicker extends Component { const [formattedDate] = this.formatValue(this.date, this.getOptions(useStatic)); if (formattedDate !== null) { this.inputRef.el.value = formattedDate; + this.props.onUpdateInput(formattedDate); } } @@ -266,6 +267,8 @@ DatePicker.defaultProps = { minDate: DateTime.fromObject({ year: 1000 }), useCurrent: false, widgetParent: "body", + onInput: () => {}, + onUpdateInput: () => {}, }; DatePicker.props = { // Components props @@ -309,6 +312,8 @@ DatePicker.props = { widgetParent: { type: String, optional: true }, daysOfWeekDisabled: { type: Array, optional: true }, placeholder: { type: String, optional: true }, + onInput: { type: Function, optional: true }, + onUpdateInput: { type: Function, optional: true }, }; DatePicker.template = "web.DatePicker"; diff --git a/addons/web/static/src/core/datepicker/datepicker.xml b/addons/web/static/src/core/datepicker/datepicker.xml index 31eb05907d9..99a82e6924c 100644 --- a/addons/web/static/src/core/datepicker/datepicker.xml +++ b/addons/web/static/src/core/datepicker/datepicker.xml @@ -13,6 +13,7 @@ t-att-readonly="props.readonly" t-ref="input" t-on-change="() => this.onDateChange()" + t-on-input="(ev) => this.props.onInput(ev)" t-on-click="onInputClick" t-on-keydown="onInputKeydown" /> diff --git a/addons/web/static/src/views/fields/date/date_field.js b/addons/web/static/src/views/fields/date/date_field.js index e78761140e7..61957696dd4 100644 --- a/addons/web/static/src/views/fields/date/date_field.js +++ b/addons/web/static/src/views/fields/date/date_field.js @@ -10,6 +10,13 @@ import { standardFieldProps } from "../standard_field_props"; import { Component } from "@odoo/owl"; export class DateField extends Component { + setup() { + /** + * The last value that has been commited to the model. + * Not changed in case of invalid field value. + */ + this.lastSetValue = null; + } get isDateTime() { return this.props.record.fields[this.props.name].type === "datetime"; } @@ -28,6 +35,13 @@ export class DateField extends Component { this.props.update(date); } } + onDatePickerInput(ev) { + this.props.setDirty(ev.target.value !== this.lastSetValue); + } + onUpdateInput(date) { + this.props.setDirty(false); + this.lastSetValue = date; + } } DateField.template = "web.DateField"; diff --git a/addons/web/static/src/views/fields/date/date_field.xml b/addons/web/static/src/views/fields/date/date_field.xml index 6d261adc464..679adfcbbfb 100644 --- a/addons/web/static/src/views/fields/date/date_field.xml +++ b/addons/web/static/src/views/fields/date/date_field.xml @@ -12,6 +12,8 @@ inputId="props.id" placeholder="props.placeholder" onDateTimeChanged="(date) => this.onDateTimeChanged(date)" + onInput.bind="onDatePickerInput" + onUpdateInput.bind="onUpdateInput" /> diff --git a/addons/web/static/src/views/fields/datetime/datetime_field.js b/addons/web/static/src/views/fields/datetime/datetime_field.js index 9d5ef472c8a..9eaa36ba482 100644 --- a/addons/web/static/src/views/fields/datetime/datetime_field.js +++ b/addons/web/static/src/views/fields/datetime/datetime_field.js @@ -9,6 +9,13 @@ import { standardFieldProps } from "../standard_field_props"; import { Component } from "@odoo/owl"; export class DateTimeField extends Component { + setup() { + /** + * The last value that has been commited to the model. + * Not changed in case of invalid field value. + */ + this.lastSetValue = null; + } get formattedValue() { return formatDateTime(this.props.value); } @@ -18,6 +25,13 @@ export class DateTimeField extends Component { this.props.update(date); } } + onDatePickerInput(ev) { + this.props.setDirty(ev.target.value !== this.lastSetValue); + } + onUpdateInput(date) { + this.props.setDirty(false); + this.lastSetValue = date; + } } DateTimeField.template = "web.DateTimeField"; diff --git a/addons/web/static/src/views/fields/datetime/datetime_field.xml b/addons/web/static/src/views/fields/datetime/datetime_field.xml index de6b6103ada..353ee429950 100644 --- a/addons/web/static/src/views/fields/datetime/datetime_field.xml +++ b/addons/web/static/src/views/fields/datetime/datetime_field.xml @@ -12,6 +12,8 @@ inputId="props.id" placeholder="props.placeholder" onDateTimeChanged="(datetime) => this.onDateTimeChanged(datetime)" + onInput.bind="onDatePickerInput" + onUpdateInput.bind="onUpdateInput" /> diff --git a/addons/web/static/src/views/fields/field.js b/addons/web/static/src/views/fields/field.js index c5dccfd88ec..77f24f93bdc 100644 --- a/addons/web/static/src/views/fields/field.js +++ b/addons/web/static/src/views/fields/field.js @@ -299,4 +299,4 @@ Field.forbiddenAttributeNames = { decorations: `You cannot use the "decorations" attribute name as it is used as generated prop name for the composite decoration- attributes.`, }; Field.props = ["fieldInfo?", "*"]; -Field.defaultProps = { fieldInfo: {} }; +Field.defaultProps = { fieldInfo: {}, setDirty: () => {} }; diff --git a/addons/web/static/src/views/form/form_compiler.js b/addons/web/static/src/views/form/form_compiler.js index 9e36ab65fb6..bea48f6275f 100644 --- a/addons/web/static/src/views/form/form_compiler.js +++ b/addons/web/static/src/views/form/form_compiler.js @@ -209,6 +209,7 @@ export class FormCompiler extends ViewCompiler { dynamicLabel(label); } this.encounteredFields[fieldName] = dynamicLabel; + field.setAttribute("setDirty", `__comp__.props.setFieldAsDirty`); return field; } diff --git a/addons/web/static/src/views/form/form_controller.js b/addons/web/static/src/views/form/form_controller.js index 5418a985a4d..d0397f1db47 100644 --- a/addons/web/static/src/views/form/form_controller.js +++ b/addons/web/static/src/views/form/form_controller.js @@ -103,6 +103,7 @@ export class FormController extends Component { this.ui = useService("ui"); this.state = useState({ isDisabled: false, + fieldIsDirty: false, }); useBus(this.ui.bus, "resize", this.render); @@ -397,6 +398,10 @@ export class FormController extends Component { this.state.isDisabled = false; } + setFieldAsDirty(dirty) { + this.state.fieldIsDirty = dirty; + } + async beforeExecuteActionButton(clickParams) { if (clickParams.special !== "cancel") { return this.model.root diff --git a/addons/web/static/src/views/form/form_controller.xml b/addons/web/static/src/views/form/form_controller.xml index 2289433124b..21fcf44ec9e 100644 --- a/addons/web/static/src/views/form/form_controller.xml +++ b/addons/web/static/src/views/form/form_controller.xml @@ -29,7 +29,7 @@ - + @@ -37,7 +37,7 @@ - + diff --git a/addons/web/static/src/views/form/form_renderer.js b/addons/web/static/src/views/form/form_renderer.js index 6f4091c97b5..ee7241d7c3a 100644 --- a/addons/web/static/src/views/form/form_renderer.js +++ b/addons/web/static/src/views/form/form_renderer.js @@ -118,6 +118,7 @@ FormRenderer.props = { disableViewButtons: { type: Function, optional: true }, onNotebookPageChange: { type: Function, optional: true }, activeNotebookPages: { type: Object, optional: true }, + setFieldAsDirty: { type: Function, optional: true }, }; FormRenderer.defaultProps = { activeNotebookPages: {}, diff --git a/addons/web/static/src/views/form/form_status_indicator/form_status_indicator.js b/addons/web/static/src/views/form/form_status_indicator/form_status_indicator.js index e125d212436..e510e1ed1b5 100644 --- a/addons/web/static/src/views/form/form_status_indicator/form_status_indicator.js +++ b/addons/web/static/src/views/form/form_status_indicator/form_status_indicator.js @@ -12,7 +12,7 @@ export class FormStatusIndicator extends Component { return this.props.model.root.isValid ? "dirty" : "invalid"; } else if (!this.props.model.root.isValid) { return "invalid"; - } else if (this.props.model.root.isDirty) { + } else if (this.props.model.root.isDirty || this.props.fieldIsDirty) { return "dirty"; } else { return "saved"; @@ -32,4 +32,5 @@ FormStatusIndicator.props = { save: Function, discard: Function, isDisabled: Boolean, + fieldIsDirty: Boolean, }; diff --git a/addons/web/static/tests/views/form/form_compiler_tests.js b/addons/web/static/tests/views/form/form_compiler_tests.js index fbf617013ac..d0e467b26e3 100644 --- a/addons/web/static/tests/views/form/form_compiler_tests.js +++ b/addons/web/static/tests/views/form/form_compiler_tests.js @@ -53,7 +53,7 @@ QUnit.module("Form Compiler", (hooks) => { const expected = /*xml*/ `
- +
`; @@ -68,7 +68,7 @@ QUnit.module("Form Compiler", (hooks) => {
lol - +
`; @@ -89,14 +89,14 @@ QUnit.module("Form Compiler", (hooks) => { - + - + @@ -126,7 +126,7 @@ QUnit.module("Form Compiler", (hooks) => {
-
+
@@ -151,10 +151,10 @@ QUnit.module("Form Compiler", (hooks) => { const expected = /*xml*/ ` - + - + `; @@ -168,7 +168,7 @@ QUnit.module("Form Compiler", (hooks) => { `; const expected = /*xml*/ ` - + `; assert.areContentEquivalent(compileTemplate(arch), expected); @@ -319,7 +319,8 @@ QUnit.module("Form Compiler", (hooks) => { + fieldInfo="__comp__.props.archInfo.fieldNodes['bar']" + setDirty="__comp__.props.setFieldAsDirty"/>
`; diff --git a/addons/web/static/tests/views/form/form_view_tests.js b/addons/web/static/tests/views/form/form_view_tests.js index 3b1029be563..15e13836317 100644 --- a/addons/web/static/tests/views/form/form_view_tests.js +++ b/addons/web/static/tests/views/form/form_view_tests.js @@ -4144,7 +4144,7 @@ QUnit.module("Views", (hooks) => { assert.deepEqual( [...target.querySelectorAll(".o_menu_item")].map((el) => el.textContent), ["Duplicate", "Delete"], - "Should not contain an Archive action", + "Should not contain an Archive action" ); }); @@ -12699,27 +12699,30 @@ QUnit.module("Views", (hooks) => { assert.verifySteps(["get_views", "onchange", "create", "read"]); }); - QUnit.test("save a form view with a duplicated invisible required field", async function (assert) { - serverData.models.partner.fields.text = { string: "Text", type: "char", required: 1 }; + QUnit.test( + "save a form view with a duplicated invisible required field", + async function (assert) { + serverData.models.partner.fields.text = { string: "Text", type: "char", required: 1 }; - await makeView({ - type: "form", - resModel: "partner", - serverData, - arch: ` + await makeView({ + type: "form", + resModel: "partner", + serverData, + arch: `
`, - }); + }); - await clickSave(target); + await clickSave(target); - assert.containsOnce(target, ".o_form_label.o_field_invalid"); - assert.containsOnce(target, ".o_field_char.o_field_invalid"); - }); + assert.containsOnce(target, ".o_form_label.o_field_invalid"); + assert.containsOnce(target, ".o_field_char.o_field_invalid"); + } + ); QUnit.test( "save a form view with an invisible required field in a x2many", @@ -12887,6 +12890,63 @@ QUnit.module("Views", (hooks) => { assert.containsNone(target, ".o_form_status_indicator_buttons.invisible"); }); + QUnit.test("status indicator: field dirty state", async (assert) => { + // this test check that the indicator don't need the onchange to be displayed + await makeView({ + type: "form", + resModel: "partner", + resId: 1, + serverData, + arch: `
`, + }); + + assert.containsOnce(target, ".o_form_status_indicator_buttons.invisible"); + + const input = target.querySelector(".o_field_widget input"); + input.value = "dirty"; + await triggerEvent(input, null, "input"); + + assert.containsNone(target, ".o_form_status_indicator_buttons.invisible"); + }); + + QUnit.test("status indicator: field dirty state (date)", async (assert) => { + // this test check that the indicator don't need the onchange to be displayed + await makeView({ + type: "form", + resModel: "partner", + resId: 1, + serverData, + arch: `
`, + }); + + assert.containsOnce(target, ".o_form_status_indicator_buttons.invisible"); + + const input = target.querySelector(".o_field_widget input"); + input.value = "03/26/2019"; + await triggerEvent(input, null, "input"); + + assert.containsNone(target, ".o_form_status_indicator_buttons.invisible"); + }); + + QUnit.test("status indicator: field dirty state (datetime)", async (assert) => { + // this test check that the indicator don't need the onchange to be displayed + await makeView({ + type: "form", + resModel: "partner", + resId: 1, + serverData, + arch: `
`, + }); + + assert.containsOnce(target, ".o_form_status_indicator_buttons.invisible"); + + const input = target.querySelector(".o_field_widget input"); + input.value = "12/12/2012 11:55:05"; + await triggerEvent(input, null, "input"); + + assert.containsNone(target, ".o_form_status_indicator_buttons.invisible"); + }); + QUnit.test("status indicator: save dirty state", async (assert) => { await makeView({ type: "form", diff --git a/addons/web/static/tests/webclient/settings_form_view/settings_form_view_tests.js b/addons/web/static/tests/webclient/settings_form_view/settings_form_view_tests.js index c40827cdcb2..ac39cfd88e4 100644 --- a/addons/web/static/tests/webclient/settings_form_view/settings_form_view_tests.js +++ b/addons/web/static/tests/webclient/settings_form_view/settings_form_view_tests.js @@ -1518,7 +1518,7 @@ QUnit.module("SettingsFormView", (hooks) => { - + `; @@ -1559,7 +1559,7 @@ QUnit.module("SettingsFormView", (hooks) => { const expectedCompiled = ` - + `; assert.areEquivalent( compiled.querySelector("SearchableSetting div.text-muted").innerHTML,