[FIX] mail: traceback when switch nav list

When a nav list is open, if there is a new props provided, a error
could be thrown. Reproduce:
1. write "@aUser #ge" in the composer
2. click on the @aUser => traceback

The problem is because when NavigableList is open,
the `props.optionTemplate` will change before `state.options`,
leading to a mismatch between the template and the record.

This commit solves the issue by removing syncing
props.options and state.options altogether.
This felt needed to enrich options with id,
but this is unnecessary because the forged id
was simply the index of option in the list.

closes odoo/odoo#144260

X-original-commit: b41f25bc7b41799bd34e27e8e1013d7a85320bcc
Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
This commit is contained in:
zel-odoo
2023-11-30 19:55:04 +00:00
parent 2270a38ef0
commit 0ae059848f
3 changed files with 47 additions and 30 deletions
@@ -28,9 +28,8 @@ export class NavigableList extends Component {
setup() {
this.rootRef = useRef("root");
this.state = useState({
activeOption: null,
activeIndex: null,
open: false,
options: [],
});
this.hotkey = useService("hotkey");
this.hotkeysToRemove = [];
@@ -58,33 +57,22 @@ export class NavigableList extends Component {
}
get show() {
return Boolean(this.state.open && (this.props.isLoading || this.state.options.length));
return Boolean(this.state.open && (this.props.isLoading || this.props.options.length));
}
open() {
this.load();
this.state.open = true;
this.state.activeIndex = null;
this.navigate("first");
}
close() {
this.state.open = false;
this.state.activeOption = null;
this.state.activeIndex = null;
}
load() {
this.state.options = [];
this.state.options = this.props.options.map((option, index) => ({
...option,
id: index,
}));
}
isActiveOption(option) {
return this.state.activeOption?.id === option.id;
}
selectOption(ev, option, params = {}) {
selectOption(ev, index, params = {}) {
const option = this.props.options[index];
if (option.unselectable) {
this.close();
return;
@@ -96,14 +84,14 @@ export class NavigableList extends Component {
}
navigate(direction) {
const activeOptionId = this.state.activeOption ? this.state.activeOption.id : -1;
const activeOptionId = this.state.activeIndex !== null ? this.state.activeIndex : 0;
let targetId = undefined;
switch (direction) {
case "first":
targetId = 0;
break;
case "last":
targetId = this.state.options.length - 1;
targetId = this.props.options.length - 1;
break;
case "previous":
targetId = activeOptionId - 1;
@@ -114,7 +102,7 @@ export class NavigableList extends Component {
break;
case "next":
targetId = activeOptionId + 1;
if (targetId > this.state.options.length - 1) {
if (targetId > this.props.options.length - 1) {
this.navigate("first");
return;
}
@@ -122,7 +110,7 @@ export class NavigableList extends Component {
default:
return;
}
this.state.activeOption = this.state.options.find((o) => o.id === targetId);
this.state.activeIndex = targetId;
}
onKeydown(ev) {
@@ -132,11 +120,11 @@ export class NavigableList extends Component {
const hotkey = getActiveHotkey(ev);
switch (hotkey) {
case "enter":
if (!this.show || !this.state.activeOption) {
if (!this.show || this.state.activeIndex === null) {
return;
}
markEventHandled(ev, "NavigableList.select");
this.selectOption(ev, this.state.activeOption);
this.selectOption(ev, this.state.activeIndex);
break;
case "escape":
markEventHandled(ev, "NavigableList.close");
@@ -157,7 +145,7 @@ export class NavigableList extends Component {
ev.preventDefault();
}
onOptionMouseEnter(option) {
this.state.activeOption = option;
onOptionMouseEnter(index) {
this.state.activeIndex = index;
}
}
@@ -12,13 +12,13 @@
</div>
<t t-else="">
<div
t-foreach="state.options" t-as="option" t-key="option.id"
t-foreach="props.options" t-as="option" t-key="option_index"
class="o-mail-NavigableList-item"
t-att-class="option.classList"
t-on-mouseenter="() => this.onOptionMouseEnter(option)"
t-on-click="(ev) => this.selectOption(ev, option)"
t-on-mouseenter="() => this.onOptionMouseEnter(option_index)"
t-on-click="(ev) => this.selectOption(ev, option_index)"
>
<a href="#" class="d-flex align-items-center w-100 py-2 px-4" t-att-class="{ 'o-mail-NavigableList-active bg-300': isActiveOption(option) }">
<a href="#" class="d-flex align-items-center w-100 py-2 px-4" t-att-class="{ 'o-mail-NavigableList-active bg-300': state.activeIndex === option_index }">
<t t-if="props.optionTemplate" t-call="{{ props.optionTemplate }}"/>
<t t-else="" t-esc="option.label"/>
</a>
@@ -388,6 +388,35 @@ QUnit.test("pending mentions are kept when toggling composer", async () => {
await contains(".o-mail-Message-body a.o_mail_redirect", { text: "@Mitchell Admin" });
});
QUnit.test("composer suggestion should match with input selection", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({
email: "testpartner@odoo.com",
name: "Luigi",
});
const channelId = pyEnv["discuss.channel"].create({
name: "Mario Party",
channel_member_ids: [
Command.create({ partner_id: pyEnv.currentPartnerId }),
Command.create({ partner_id: partnerId }),
],
});
const { openDiscuss } = await start();
openDiscuss(channelId);
await contains(".o-mail-Composer-input", { value: "" });
await insertText(".o-mail-Composer-input", "#");
await contains(".o-mail-Composer-suggestion", { text: "#Mario Party" });
await click(".o-mail-Composer-suggestion");
await contains(".o-mail-Composer-input", { value: "#Mario Party " });
await insertText(".o-mail-Composer-input", "@");
await contains(".o-mail-Composer-suggestion", { text: "Luigi" });
$(".o-mail-Composer-input")[0].setSelectionRange(3, 3);
await contains(".o-mail-Composer-suggestion", { text: "#Mario Party" });
const textarea = $(".o-mail-Composer-input")[0];
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
await contains(".o-mail-Composer-suggestion", { text: "Luigi" });
});
QUnit.test('do not post message on channel with "SHIFT-Enter" keyboard shortcut', async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "general" });