[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:
@@ -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>
|
||||
|
||||
@@ -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="""" 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 && 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 && 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 && 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 && 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 && scope.className" />
|
||||
<Field id="'charfield'" name="'charfield'" record="__comp__.props.record" fieldInfo="__comp__.props.archInfo.fieldNodes['charfield']" setDirty="__comp__.props.setFieldAsDirty" class="scope && scope.className" />
|
||||
</t>
|
||||
</InnerGroup>
|
||||
</t>
|
||||
@@ -126,7 +126,7 @@ QUnit.module("Form Compiler", (hooks) => {
|
||||
<InnerGroup class="scope && 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 && scope.className || "" }}" 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) => __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="["\`My\\" little ' Label\`"]">
|
||||
<FormLabel id="'display_name'" fieldName="'display_name'" record="__comp__.props.record" fieldInfo="__comp__.props.archInfo.fieldNodes['display_name']" className=""highhopes"" string="\`My" 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,
|
||||
|
||||
Reference in New Issue
Block a user