[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) <aab@odoo.com>
This commit is contained in:
Jorge Pinna Puissant
2023-02-01 10:25:04 +01:00
parent fdf2f0c095
commit 89c2a3978e
15 changed files with 135 additions and 28 deletions
@@ -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";
@@ -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"
/>
@@ -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";
@@ -12,6 +12,8 @@
inputId="props.id"
placeholder="props.placeholder"
onDateTimeChanged="(date) => this.onDateTimeChanged(date)"
onInput.bind="onDatePickerInput"
onUpdateInput.bind="onUpdateInput"
/>
</t>
</t>
@@ -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";
@@ -12,6 +12,8 @@
inputId="props.id"
placeholder="props.placeholder"
onDateTimeChanged="(datetime) => this.onDateTimeChanged(datetime)"
onInput.bind="onDatePickerInput"
onUpdateInput.bind="onUpdateInput"
/>
</t>
</t>
+1 -1
View File
@@ -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-<something> attributes.`,
};
Field.props = ["fieldInfo?", "*"];
Field.defaultProps = { fieldInfo: {} };
Field.defaultProps = { fieldInfo: {}, setDirty: () => {} };
@@ -209,6 +209,7 @@ export class FormCompiler extends ViewCompiler {
dynamicLabel(label);
}
this.encounteredFields[fieldName] = dynamicLabel;
field.setAttribute("setDirty", `__comp__.props.setFieldAsDirty`);
return field;
}
@@ -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
@@ -29,7 +29,7 @@
</t>
<t t-set-slot="control-panel-status-indicator">
<t t-if="canEdit">
<FormStatusIndicator model="model" discard.bind="discard" save.bind="saveButtonClicked" isDisabled="state.isDisabled" />
<FormStatusIndicator model="model" discard.bind="discard" save.bind="saveButtonClicked" isDisabled="state.isDisabled" fieldIsDirty="state.fieldIsDirty" />
</t>
</t>
<t t-set-slot="control-panel-create-button">
@@ -37,7 +37,7 @@
<button type="button" class="btn btn-outline-primary o_form_button_create" data-hotkey="c" t-on-click.stop="create" t-att-disabled="state.isDisabled">New</button>
</t>
</t>
<t t-component="props.Renderer" record="model.root" Compiler="props.Compiler" archInfo="archInfo" translateAlert="translateAlert" enableViewButtons.bind="enableButtons" disableViewButtons.bind="disableButtons" onNotebookPageChange.bind="onNotebookPageChange" activeNotebookPages="props.state and props.state.activeNotebookPages"/>
<t t-component="props.Renderer" record="model.root" Compiler="props.Compiler" archInfo="archInfo" translateAlert="translateAlert" setFieldAsDirty.bind="setFieldAsDirty" enableViewButtons.bind="enableButtons" disableViewButtons.bind="disableButtons" onNotebookPageChange.bind="onNotebookPageChange" activeNotebookPages="props.state and props.state.activeNotebookPages"/>
</Layout>
</div>
</div>
@@ -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: {},
@@ -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,
};
@@ -53,7 +53,7 @@ QUnit.module("Form Compiler", (hooks) => {
const expected = /*xml*/ `
<t>
<div t-att-class="__comp__.props.class" t-attf-class="{{__comp__.props.record.isInEdition ? 'o_form_editable' : 'o_form_readonly'}} d-block {{ __comp__.props.record.isDirty ? 'o_form_dirty' : !__comp__.props.record.isVirtual ? 'o_form_saved' : '' }}" class="o_form_nosheet" t-ref="compiled_view_root">
<Field id="'test'" name="'test'" record="__comp__.props.record" fieldInfo="__comp__.props.archInfo.fieldNodes['test']" />
<Field id="'test'" name="'test'" record="__comp__.props.record" fieldInfo="__comp__.props.archInfo.fieldNodes['test']" setDirty="__comp__.props.setFieldAsDirty"/>
<FormLabel id="'test'" fieldName="'test'" record="__comp__.props.record" fieldInfo="__comp__.props.archInfo.fieldNodes['test']" className="&quot;&quot;" string="\`\`" />
</div>
</t>`;
@@ -68,7 +68,7 @@ QUnit.module("Form Compiler", (hooks) => {
<div t-att-class="__comp__.props.class" t-attf-class="{{__comp__.props.record.isInEdition ? 'o_form_editable' : 'o_form_readonly'}} d-block {{ __comp__.props.record.isDirty ? 'o_form_dirty' : !__comp__.props.record.isVirtual ? 'o_form_saved' : '' }}" class="o_form_nosheet" t-ref="compiled_view_root">
<div class="someClass">
lol
<Field id="'display_name'" name="'display_name'" record="__comp__.props.record" fieldInfo="__comp__.props.archInfo.fieldNodes['display_name']"/>
<Field id="'display_name'" name="'display_name'" record="__comp__.props.record" fieldInfo="__comp__.props.archInfo.fieldNodes['display_name']" setDirty="__comp__.props.setFieldAsDirty"/>
</div>
</div>
</t>`;
@@ -89,14 +89,14 @@ QUnit.module("Form Compiler", (hooks) => {
<t t-set-slot="item_0" type="'item'" sequence="0" t-slot-scope="scope" isVisible="true" itemSpan="1">
<InnerGroup class="scope &amp;&amp; scope.className">
<t t-set-slot="item_0" type="'item'" sequence="0" t-slot-scope="scope" props="{id:'display_name',fieldName:'display_name',record:__comp__.props.record,string:__comp__.props.record.fields.display_name.string,fieldInfo:__comp__.props.archInfo.fieldNodes['display_name']}" Component="__comp__.constructor.components.FormLabel" subType="'item_component'" isVisible="true" itemSpan="2">
<Field id="'display_name'" name="'display_name'" record="__comp__.props.record" fieldInfo="__comp__.props.archInfo.fieldNodes['display_name']" class="scope &amp;&amp; scope.className" />
<Field id="'display_name'" name="'display_name'" record="__comp__.props.record" fieldInfo="__comp__.props.archInfo.fieldNodes['display_name']" setDirty="__comp__.props.setFieldAsDirty" class="scope &amp;&amp; scope.className" />
</t>
</InnerGroup>
</t>
<t t-set-slot="item_1" type="'item'" sequence="1" t-slot-scope="scope" isVisible="true" itemSpan="1">
<InnerGroup class="scope &amp;&amp; scope.className">
<t t-set-slot="item_0" type="'item'" sequence="0" t-slot-scope="scope" props="{id:'charfield',fieldName:'charfield',record:__comp__.props.record,string:__comp__.props.record.fields.charfield.string,fieldInfo:__comp__.props.archInfo.fieldNodes['charfield']}" Component="__comp__.constructor.components.FormLabel" subType="'item_component'" isVisible="true" itemSpan="2">
<Field id="'charfield'" name="'charfield'" record="__comp__.props.record" fieldInfo="__comp__.props.archInfo.fieldNodes['charfield']" class="scope &amp;&amp; scope.className" />
<Field id="'charfield'" name="'charfield'" record="__comp__.props.record" fieldInfo="__comp__.props.archInfo.fieldNodes['charfield']" setDirty="__comp__.props.setFieldAsDirty" class="scope &amp;&amp; scope.className" />
</t>
</InnerGroup>
</t>
@@ -126,7 +126,7 @@ QUnit.module("Form Compiler", (hooks) => {
<InnerGroup class="scope &amp;&amp; scope.className">
<t t-set-slot="item_0" type="'item'" sequence="0" t-slot-scope="scope" isVisible="true" itemSpan="1">
<div t-att-class="__comp__.props.class" t-attf-class="{{__comp__.props.record.isInEdition ? 'o_form_editable' : 'o_form_readonly'}} d-block {{ __comp__.props.record.isDirty ? 'o_form_dirty' : !__comp__.props.record.isVirtual ? 'o_form_saved' : '' }} {{scope &amp;&amp; scope.className || &quot;&quot; }}" class="o_form_nosheet">
<div><Field id="'test'" name="'test'" record="__comp__.props.record" fieldInfo="__comp__.props.archInfo.fieldNodes['test']"/></div>
<div><Field id="'test'" name="'test'" record="__comp__.props.record" fieldInfo="__comp__.props.archInfo.fieldNodes['test']" setDirty="__comp__.props.setFieldAsDirty"/></div>
</div>
</t>
</InnerGroup>
@@ -151,10 +151,10 @@ QUnit.module("Form Compiler", (hooks) => {
const expected = /*xml*/ `
<Notebook defaultPage="__comp__.props.record.isNew ? undefined : __comp__.props.activeNotebookPages[0]" onPageUpdate="(page) =&gt; __comp__.props.onNotebookPageChange(0, page)">
<t t-set-slot="page_1" title="\`Page1\`" name="\`p1\`" isVisible="true">
<Field id="'charfield'" name="'charfield'" record="__comp__.props.record" fieldInfo="__comp__.props.archInfo.fieldNodes['charfield']"/>
<Field id="'charfield'" name="'charfield'" record="__comp__.props.record" fieldInfo="__comp__.props.archInfo.fieldNodes['charfield']" setDirty="__comp__.props.setFieldAsDirty"/>
</t>
<t t-set-slot="page_2" title="\`Page2\`" name="\`p2\`" isVisible="true">
<Field id="'display_name'" name="'display_name'" record="__comp__.props.record" fieldInfo="__comp__.props.archInfo.fieldNodes['display_name']"/>
<Field id="'display_name'" name="'display_name'" record="__comp__.props.record" fieldInfo="__comp__.props.archInfo.fieldNodes['display_name']" setDirty="__comp__.props.setFieldAsDirty"/>
</t>
</Notebook>`;
@@ -168,7 +168,7 @@ QUnit.module("Form Compiler", (hooks) => {
</form>`;
const expected = /*xml*/ `
<Field id="'display_name'" name="'display_name'" record="__comp__.props.record" fieldInfo="__comp__.props.archInfo.fieldNodes['display_name']"/>
<Field id="'display_name'" name="'display_name'" record="__comp__.props.record" fieldInfo="__comp__.props.archInfo.fieldNodes['display_name']" setDirty="__comp__.props.setFieldAsDirty"/>
`;
assert.areContentEquivalent(compileTemplate(arch), expected);
@@ -319,7 +319,8 @@ QUnit.module("Form Compiler", (hooks) => {
<Field id="'bar'"
name="'bar'"
record="__comp__.props.record"
fieldInfo="__comp__.props.archInfo.fieldNodes['bar']"/>
fieldInfo="__comp__.props.archInfo.fieldNodes['bar']"
setDirty="__comp__.props.setFieldAsDirty"/>
</t>
<label>label with content</label>
</Setting>`;
@@ -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: `
<form>
<group>
<field name="text"/>
<field name="text" invisible="1"/>
</group>
</form>`,
});
});
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: `<form><field name="foo"/></form>`,
});
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: `<form><field name="date"/></form>`,
});
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: `<form><field name="datetime"/></form>`,
});
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",
@@ -1518,7 +1518,7 @@ QUnit.module("SettingsFormView", (hooks) => {
<SettingsApp key="\`crm\`" string="\`CRM\`" imgurl="\`/crm/static/description/icon.png\`" selectedTab="settings.selectedTab">
<SearchableSetting title="\`\`" help="\`\`" companyDependent="false" documentation="\`\`" record="__comp__.props.record" string="\`\`" addLabel="true" labels="[&quot;\`My\\&quot; little ' Label\`&quot;]">
<FormLabel id="'display_name'" fieldName="'display_name'" record="__comp__.props.record" fieldInfo="__comp__.props.archInfo.fieldNodes['display_name']" className="&quot;highhopes&quot;" string="\`My&quot; little ' Label\`"/>
<Field id="'display_name'" name="'display_name'" record="__comp__.props.record" fieldInfo="__comp__.props.archInfo.fieldNodes['display_name']"/>
<Field id="'display_name'" name="'display_name'" record="__comp__.props.record" fieldInfo="__comp__.props.archInfo.fieldNodes['display_name']" setDirty="__comp__.props.setFieldAsDirty"/>
</SearchableSetting>
</SettingsApp>
</SettingsPage>`;
@@ -1559,7 +1559,7 @@ QUnit.module("SettingsFormView", (hooks) => {
const expectedCompiled = `
<HighlightText originalText="\`this is Baz value: \`"/>
<Field id="'baz'" name="'baz'" record="__comp__.props.record" fieldInfo="__comp__.props.archInfo.fieldNodes['baz']"/>
<Field id="'baz'" name="'baz'" record="__comp__.props.record" fieldInfo="__comp__.props.archInfo.fieldNodes['baz']" setDirty="__comp__.props.setFieldAsDirty"/>
<HighlightText originalText="\` and this is the after text\`"/>`;
assert.areEquivalent(
compiled.querySelector("SearchableSetting div.text-muted").innerHTML,