[IMP] web,mail: many2many avatar quick assign

Purpose:

Allow quick assignation of records in the kanban views (and other places
by extension).
Currently, if you want to work in a board-like env (e.g. tasks in
kanban, crm in pipeline, that kind of thing), having the possibility to
quickly assign/unassign records without having to open them fullscreen
can be really useful => it should be possible to "quick edit" these
fields in kanban views easily, without switching to form views.

If you want to keep the field readonly don't forget to add the readonly
attribute.

Task-id: 3121207
Part-of: odoo/odoo#114400
This commit is contained in:
Romain Estievenart
2023-03-10 17:54:56 +01:00
parent 7bd93cc64b
commit 66321f76aa
9 changed files with 340 additions and 119 deletions
@@ -366,8 +366,8 @@ QUnit.module('hr', {}, function () {
res_model: 'm2x.avatar.employee',
views: [[false, 'kanban']],
});
assert.containsN(document.body, '.o_kanban_record:first .o_field_many2many_avatar_employee img.o_m2m_avatar', 2,
"should have 2 avatar images");
assert.containsN(document.body, '.o_kanban_record:first .o_field_many2many_avatar_employee img.o_m2m_avatar', 3,
"should have 2 avatar images and the default empty avatar");
assert.strictEqual(document.querySelector('.o_kanban_record .o_field_many2many_avatar_employee img.o_m2m_avatar').getAttribute('data-src'),
`/web/image/hr.employee.public/${hrEmployeePublicId1}/avatar_128`,
"should have correct avatar image");
@@ -1,12 +1,16 @@
/** @odoo-module **/
import { patch } from "@web/core/utils/patch";
import { registry } from "@web/core/registry";
import { TagsList } from "@web/views/fields/many2many_tags/tags_list";
import {
Many2ManyTagsAvatarField,
ListKanbanMany2ManyTagsAvatarField,
many2ManyTagsAvatarField,
listKanbanMany2ManyTagsAvatarField,
ListMany2ManyTagsAvatarField,
listMany2ManyTagsAvatarField,
KanbanMany2ManyTagsAvatarField,
kanbanMany2ManyTagsAvatarField,
KanbanMany2ManyTagsAvatarFieldTagsList,
} from "@web/views/fields/many2many_tags_avatar/many2many_tags_avatar_field";
import { useOpenChat } from "@mail/views/open_chat_hook";
import { useAssignUserCommand } from "@mail/views/fields/assign_user_command_hook";
@@ -14,28 +18,29 @@ import { useAssignUserCommand } from "@mail/views/fields/assign_user_command_hoo
export class Many2ManyAvatarUserTagsList extends TagsList {}
Many2ManyAvatarUserTagsList.template = "mail.Many2ManyAvatarUserTagsList";
export class Many2ManyTagsAvatarUserField extends Many2ManyTagsAvatarField {
const userChatter = {
setup() {
super.setup();
this._super(...arguments);
this.openChat = useOpenChat(this.relation);
if (this.props.withCommand) {
useAssignUserCommand();
}
}
},
get tags() {
return super.tags.map((tag) => ({
...tag,
onImageClicked: () => {
this.openChat(tag.resId);
},
}));
}
}
Many2ManyTagsAvatarUserField.components = {
...Many2ManyTagsAvatarField.components,
TagsList: Many2ManyAvatarUserTagsList,
getTagProps(record) {
return {
...this._super(...arguments),
onImageClicked: () => this.openChat(record.resId),
};
},
};
export class Many2ManyTagsAvatarUserField extends Many2ManyTagsAvatarField {
static components = {
...Many2ManyTagsAvatarField.components,
TagsList: Many2ManyAvatarUserTagsList,
};
}
patch(Many2ManyTagsAvatarUserField.prototype, "mail", userChatter);
export const many2ManyTagsAvatarUserField = {
...many2ManyTagsAvatarField,
@@ -45,56 +50,48 @@ export const many2ManyTagsAvatarUserField = {
registry.category("fields").add("many2many_avatar_user", many2ManyTagsAvatarUserField);
export class KanbanMany2ManyTagsAvatarUserField extends ListKanbanMany2ManyTagsAvatarField {
static props = {
...ListKanbanMany2ManyTagsAvatarField.props,
displayText: { type: Boolean, optional: true },
export class KanbanMany2ManyAvatarUserTagsList extends KanbanMany2ManyTagsAvatarFieldTagsList {
static template = "mail.KanbanMany2ManyAvatarUserTagsList";
}
export class KanbanMany2ManyTagsAvatarUserField extends KanbanMany2ManyTagsAvatarField {
static template = "mail.KanbanMany2ManyTagsAvatarUserField";
static components = {
...KanbanMany2ManyTagsAvatarField.components,
TagsList: KanbanMany2ManyAvatarUserTagsList,
};
setup() {
super.setup();
this.openChat = useOpenChat(this.relation);
if (this.props.withCommand) {
useAssignUserCommand();
}
}
get displayText() {
return (
(this.props.displayText && this.props.record.data[this.props.name].records.length === 1) ||
!this.props.readonly
);
}
get tags() {
const recordFromId = (id) =>
this.props.record.data[this.props.name].records.find((rec) => rec.id === id);
return super.tags.map((tag) => ({
...tag,
onImageClicked: () => {
this.openChat(recordFromId(tag.id).resId);
},
}));
return !this.props.readonly;
}
}
KanbanMany2ManyTagsAvatarUserField.template = "mail.KanbanMany2ManyTagsAvatarUserField";
KanbanMany2ManyTagsAvatarUserField.components = {
...ListKanbanMany2ManyTagsAvatarField.components,
TagsList: Many2ManyAvatarUserTagsList,
};
patch(KanbanMany2ManyTagsAvatarUserField.prototype, "mail", userChatter);
export const kanbanMany2ManyTagsAvatarUserField = {
...listKanbanMany2ManyTagsAvatarField,
...kanbanMany2ManyTagsAvatarField,
component: KanbanMany2ManyTagsAvatarUserField,
additionalClasses: ["o_field_many2many_tags_avatar"],
extractProps: (fieldInfo) => ({
...listKanbanMany2ManyTagsAvatarField.extractProps(fieldInfo),
displayText: fieldInfo.viewType === "list",
}),
additionalClasses: ["o_field_many2many_tags_avatar", "o_field_many2many_tags_avatar_kanban"],
};
registry.category("fields").add("kanban.many2many_avatar_user", kanbanMany2ManyTagsAvatarUserField);
export class ListMany2ManyTagsAvatarUserField extends ListMany2ManyTagsAvatarField {
static template = "mail.ListMany2ManyTagsAvatarUserField";
static components = {
...ListMany2ManyTagsAvatarField.components,
TagsList: Many2ManyAvatarUserTagsList,
};
get displayText() {
return this.props.record.data[this.props.name].records.length === 1 || !this.props.readonly;
}
}
patch(ListMany2ManyTagsAvatarUserField.prototype, "mail", userChatter);
export const listMany2ManyTagsAvatarUserField = {
...listMany2ManyTagsAvatarField,
component: ListMany2ManyTagsAvatarUserField,
additionalClasses: ["o_field_many2many_tags_avatar", "o_field_many2many_tags_avatar_list"],
};
registry.category("fields").add("kanban.many2many_avatar_user", kanbanMany2ManyTagsAvatarUserField);
registry.category("fields").add("list.many2many_avatar_user", kanbanMany2ManyTagsAvatarUserField);
registry.category("fields").add("list.many2many_avatar_user", listMany2ManyTagsAvatarUserField);
registry
.category("fields")
.add("activity.many2many_avatar_user", kanbanMany2ManyTagsAvatarUserField);
@@ -6,7 +6,20 @@
</img>
</t>
<t t-name="mail.KanbanMany2ManyTagsAvatarUserField" t-inherit="web.Many2ManyTagsAvatarField" t-inherit-mode="primary" owl="1">
<t t-name="mail.KanbanMany2ManyAvatarUserTagsList" t-inherit="web.KanbanMany2ManyTagsAvatarFieldTagsList" t-inherit-mode="primary" owl="1">
<img position="attributes">
<attribute name="t-on-click.stop.prevent">tag.onImageClicked</attribute>
</img>
</t>
<t t-name="mail.ListMany2ManyTagsAvatarUserField" t-inherit="web.Many2ManyTagsAvatarField" t-inherit-mode="primary" owl="1">
<TagsList position="attributes">
<attribute name="displayBadge">!props.readonly</attribute>
<attribute name="displayText">displayText</attribute>
</TagsList>
</t>
<t t-name="mail.KanbanMany2ManyTagsAvatarUserField" t-inherit="web.KanbanMany2ManyTagsAvatarField" t-inherit-mode="primary" owl="1">
<TagsList position="attributes">
<attribute name="displayBadge">!props.readonly</attribute>
<attribute name="displayText">displayText</attribute>
@@ -78,11 +78,11 @@ QUnit.module("mail", {}, function () {
});
QUnit.test("many2many_avatar_user in kanban view", async function (assert) {
assert.expect(5);
assert.expect(8);
patchWithCleanup(browser, {
setTimeout: async (fn) => {
await new Promise((r) => setTimeout(r))
await new Promise((r) => setTimeout(r));
fn();
},
});
@@ -136,26 +136,34 @@ QUnit.module("mail", {}, function () {
"+2",
"should have +2 in o_m2m_avatar_empty"
);
document
.querySelector(".o_kanban_record .o_field_many2many_avatar_user .o_m2m_avatar_empty")
.dispatchEvent(new Event("mouseenter"));
await nextTick();
await click(
document.querySelector(
".o_kanban_record .o_field_many2many_avatar_user .o_m2m_avatar_empty"
)
);
assert.containsOnce(
document.body,
".popover",
"should open a popover hover on o_m2m_avatar_empty"
);
const tags = document.querySelectorAll(".o_popover > .o_field_tags > .o_tag");
assert.strictEqual(tags.length, 4, "should have 4 tags");
assert.strictEqual(
document.querySelector(".popover .o-tooltip > div").innerText.trim(),
tags[0].innerText.trim(),
"Mario",
"should have a right text in popover"
);
assert.strictEqual(
tags[1].innerText.trim(),
"Yoshi",
"should have a right text in popover"
);
assert.strictEqual(
tags[2].innerText.trim(),
"Luigi",
"should have a right text in popover"
);
assert.strictEqual(
document.querySelectorAll(".popover .o-tooltip > div")[1].innerText.trim(),
"Tapu",
"should have a right text in popover"
);
assert.strictEqual(tags[3].innerText.trim(), "Tapu", "should have a right text in popover");
});
QUnit.test(
@@ -17,10 +17,12 @@ export class TagsList extends Component {
itemsVisible: { type: Number, optional: true },
tags: { type: Object, optional: true },
};
get visibleTagsCount() {
return this.props.itemsVisible - 1;
}
get visibleTags() {
if (this.props.itemsVisible && this.props.tags.length > this.props.itemsVisible) {
return this.props.tags.slice(0, this.props.itemsVisible - 1);
return this.props.tags.slice(0, this.visibleTagsCount);
}
return this.props.tags;
}
@@ -28,7 +30,7 @@ export class TagsList extends Component {
if (!this.props.itemsVisible || this.props.tags.length <= this.props.itemsVisible) {
return [];
}
return this.props.tags.slice(this.props.itemsVisible - 1);
return this.props.tags.slice(this.visibleTagsCount);
}
get tooltipInfo() {
return JSON.stringify({
@@ -1,5 +1,7 @@
/** @odoo-module **/
import { isMobileOS } from "@web/core/browser/feature_detection";
import { usePopover } from "@web/core/popover/popover_hook";
import { registry } from "@web/core/registry";
import { Many2XAutocomplete } from "@web/views/fields/relational_utils";
import {
@@ -7,6 +9,7 @@ import {
Many2ManyTagsField,
} from "@web/views/fields/many2many_tags/many2many_tags_field";
import { TagsList } from "../many2many_tags/tags_list";
import { onMounted, useRef, useState } from "@odoo/owl";
export class Many2ManyTagsAvatarField extends Many2ManyTagsField {
static template = "web.Many2ManyTagsAvatarField";
@@ -18,13 +21,11 @@ export class Many2ManyTagsAvatarField extends Many2ManyTagsField {
...Many2ManyTagsField.props,
withCommand: { type: Boolean, optional: true },
};
get tags() {
return super.tags.map((tag) => ({
...tag,
img: `/web/image/${this.relation}/${tag.resId}/avatar_128`,
onDelete: !this.props.readonly ? () => this.deleteTag(tag.id) : undefined,
}));
getTagProps(record) {
return {
...super.getTagProps(record),
img: `/web/image/${this.relation}/${record.resId}/avatar_128`,
};
}
}
@@ -39,32 +40,153 @@ export const many2ManyTagsAvatarField = {
registry.category("fields").add("many2many_tags_avatar", many2ManyTagsAvatarField);
export class ListKanbanMany2ManyTagsAvatarField extends Many2ManyTagsAvatarField {
export class ListMany2ManyTagsAvatarField extends Many2ManyTagsAvatarField {
itemsVisible = 5;
}
export const listMany2ManyTagsAvatarField = {
...many2ManyTagsAvatarField,
component: ListMany2ManyTagsAvatarField,
};
registry.category("fields").add("list.many2many_tags_avatar", listMany2ManyTagsAvatarField);
export class Many2ManyTagsAvatarFieldPopover extends Many2ManyTagsAvatarField {
static template = "web.Many2ManyTagsAvatarFieldPopover";
static props = {
...Many2ManyTagsAvatarField.props,
itemsVisible: { type: Number, optional: true },
close: { type: Function },
deleteTag: { type: Function },
updateTag: { type: Function },
};
static defaultProps = {
...Many2ManyTagsAvatarField.defaultProps,
itemsVisible: 3,
setup() {
super.setup();
this.state = useState({ tags: this.tags });
this.update = async (recordList) => {
const updatedVal = await this.props.updateTag(recordList);
this.state.tags = updatedVal.map((tag) => ({
...tag,
onDelete: () => this.deleteTag(tag.id),
}));
};
onMounted(() => {
this.autoCompleteRef.el.querySelector("input").focus();
});
}
async deleteTag(id) {
const updatedVal = await this.props.deleteTag(id);
this.state.tags = updatedVal.map((tag) => ({
...tag,
onDelete: () => this.deleteTag(tag.id),
}));
}
}
export const many2ManyTagsAvatarFieldPopover = {
...many2ManyTagsAvatarField,
component: Many2ManyTagsAvatarFieldPopover,
};
registry.category("fields").add("many2many_tags_avatar_popover", many2ManyTagsAvatarFieldPopover);
export class KanbanMany2ManyTagsAvatarFieldTagsList extends TagsList {
static template = "web.KanbanMany2ManyTagsAvatarFieldTagsList";
static props = {
...TagsList.props,
popoverProps: { type: Object },
readonly: { type: Boolean, optional: true },
};
setup() {
super.setup();
this.popover = usePopover();
this.emptyAvatar = useRef("emptyAvatar");
}
get visibleTagsCount() {
return this.props.itemsVisible;
}
closePopover() {
this.closePopoverFn();
this.closePopoverFn = null;
}
openPopover(ev) {
if (this.props.readonly) {
return;
}
if (this.closePopoverFn) {
this.closePopover();
}
this.emptyAvatar.el?.dispatchEvent(new MouseEvent("mouseleave"));
this.emptyAvatar.el?.removeAttribute("data-tooltip-template");
this.closePopoverFn = this.popover.add(
ev.currentTarget.parentElement,
Many2ManyTagsAvatarFieldPopover,
{
...this.props.popoverProps,
readonly: false,
canCreate: false,
canCreateEdit: false,
canQuickCreate: false,
},
{
position: "bottom",
onClose: () => {
this.emptyAvatar.el?.setAttribute(
"data-tooltip-template",
"web.TagsList.Tooltip"
);
},
}
);
}
get canDisplayDelete() {
return !this.props.readonly && !isMobileOS();
}
}
export class KanbanMany2ManyTagsAvatarField extends Many2ManyTagsAvatarField {
static template = "web.KanbanMany2ManyTagsAvatarField";
static components = {
...Many2ManyTagsAvatarField.component,
TagsList: KanbanMany2ManyTagsAvatarFieldTagsList,
};
itemsVisible = 2;
get isFieldReadonly() {
return this.props.record.isReadonly(this.props.name);
}
get popoverProps() {
return {
...this.props,
readonly: this.isFieldReadonly,
deleteTag: this.deleteTag.bind(this),
updateTag: this.updateTag.bind(this),
};
}
async deleteTag(id) {
super.deleteTag(id);
await this.props.record.save({ noReload: true });
return this.tags;
}
async updateTag(recordList) {
await this.update(recordList);
await this.props.record.save({ noReload: true });
return this.tags;
}
getTagProps(record) {
return {
...super.getTagProps(record),
img: `/web/image/${this.relation}/${record.resId}/avatar_128`,
onDelete: () => this.deleteTag(record.id),
};
}
}
export const listKanbanMany2ManyTagsAvatarField = {
export const kanbanMany2ManyTagsAvatarField = {
...many2ManyTagsAvatarField,
component: ListKanbanMany2ManyTagsAvatarField,
extractProps: (fieldInfo) => ({
...many2ManyTagsAvatarField.extractProps(fieldInfo),
itemsVisible: fieldInfo.viewType === "list" ? 5 : 3,
}),
component: KanbanMany2ManyTagsAvatarField,
};
registry.category("fields").add("list.many2many_tags_avatar", listKanbanMany2ManyTagsAvatarField);
registry.category("fields").add("kanban.many2many_tags_avatar", listKanbanMany2ManyTagsAvatarField);
registry.category("fields").add("kanban.many2many_tags_avatar", kanbanMany2ManyTagsAvatarField);
@@ -80,3 +80,21 @@
flex-basis: 40px;
}
}
.o_m2m_tags_avatar_field_popover {
max-width: 280px;
.dropdown-menu {
width: 275px;
}
}
.o_field_many2many_tags_avatar_kanban {
.o_field_tags .o_tag .o_delete {
margin-left: -12px;
margin-top: -4px;
width: 12px;
height: 12px;
visibility: hidden;
}
.o_allow_delete_hover:hover .o_delete {
visibility: visible;
}
}
@@ -6,7 +6,7 @@
class="o_field_tags d-inline-flex flex-wrap mw-100"
t-att-class="{'o_tags_input o_input': !props.readonly}"
>
<TagsList tags="tags" itemsVisible="props.itemsVisible"/>
<TagsList tags="tags" itemsVisible="itemsVisible"/>
<div t-if="showM2OSelectionField" class="o_field_many2many_selection d-inline-flex w-100" t-on-keydown="onAutoCompleteKeydown" t-ref="autoComplete">
<Many2XAutocomplete
id="props.id"
@@ -25,4 +25,42 @@
</div>
</t>
<t t-name="web.KanbanMany2ManyTagsAvatarFieldTagsList" t-inherit="web.TagsList" t-inherit-mode="primary" owl="1">
<xpath expr="//span[hasclass('o_m2m_avatar_empty')]" position="after">
<img t-elif="!props.readonly" src="/web/static/img/user_menu_avatar.png" title="Quick assign" alt="Quick assign"
t-on-click.stop.prevent="openPopover" class="o_m2m_avatar rounded-circle align-self-center"/>
</xpath>
<xpath expr="//span[hasclass('o_m2m_avatar_empty')]" position="attributes">
<attribute name="t-ref">emptyAvatar</attribute>
<attribute name="t-on-click.stop.prevent">openPopover</attribute>
</xpath>
<xpath expr="//span[@t-att-title='tag.text']" position="attributes">
<attribute name="t-att-class">{'o_allow_delete_hover': canDisplayDelete}</attribute>
</xpath>
<xpath expr="//a[hasclass('o_delete')]" position="attributes">
<attribute name="class" remove="fa-times" add="fa-times-circle fs-6 text-danger bg-white align-self-start z-index-1 pt-0 rounded-circle" separator=" " />
<attribute name="title">Remove</attribute>
<attribute name="aria-label">Remove</attribute>
</xpath>
</t>
<t t-name="web.KanbanMany2ManyTagsAvatarField" t-inherit="web.Many2ManyTagsAvatarField" t-inherit-mode="primary"
owl="1">
<xpath expr="//TagsList" position="attributes">
<attribute name="popoverProps">popoverProps</attribute>
<attribute name="readonly">isFieldReadonly</attribute>
</xpath>
</t>
<t t-name="web.Many2ManyTagsAvatarFieldPopover" t-inherit="web.Many2ManyTagsAvatarField" t-inherit-mode="primary" owl="1">
<xpath expr="//div[hasclass('o_field_tags')]" position="attributes">
<attribute name="class" add="p-1 o_m2m_tags_avatar_field_popover o_field_widget o_field_many2many_tags_avatar" remove="d-inline-flex mw-100" separator=" " />
</xpath>
<xpath expr="//div[hasclass('o_field_many2many_selection')]" position="attributes">
<attribute name="class" add="w-100" separator=" " />
</xpath>
<xpath expr="//TagsList" position="attributes">
<attribute name="tags" remove="tags" add="state.tags" />
</xpath>
</t>
</templates>
@@ -1,6 +1,12 @@
/** @odoo-module **/
import { click, clickSave, getFixture, selectDropdownItem } from "@web/../tests/helpers/utils";
import {
click,
clickSave,
getFixture,
triggerEvent,
selectDropdownItem,
} from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import { triggerHotkey } from "../../helpers/utils";
@@ -273,7 +279,7 @@ QUnit.module("Fields", (hooks) => {
);
QUnit.test("widget many2many_tags_avatar in kanban view", async function (assert) {
assert.expect(13);
assert.expect(16);
const records = [];
for (let id = 5; id <= 15; id++) {
@@ -336,8 +342,8 @@ QUnit.module("Fields", (hooks) => {
assert.containsN(
target,
".o_kanban_record:nth-child(2) .o_field_many2many_tags_avatar .o_tag",
3,
"should have 3 records"
2,
"should have 2 records"
);
assert.containsN(
target,
@@ -395,25 +401,42 @@ QUnit.module("Fields", (hooks) => {
"should have 9+ in o_m2m_avatar_empty"
);
// check data-tooltip attribute (used by the tooltip service)
const tag = target.querySelector(
".o_kanban_record:nth-child(3) .o_field_many2many_tags_avatar .o_m2m_avatar_empty"
// check delete
const firstTag = target.querySelector(
".o_kanban_record:nth-child(2) .o_field_many2many_tags_avatar .o_tag"
);
assert.strictEqual(
tag.dataset["tooltipTemplate"],
"web.TagsList.Tooltip",
"uses the proper tooltip template"
);
const tooltipInfo = JSON.parse(tag.dataset["tooltipInfo"]);
assert.strictEqual(
tooltipInfo.tags.map((tag) => tag.text).join(" "),
"aaa record 5",
"shows a tooltip on hover"
await triggerEvent(firstTag, null, "mouseover");
await click(firstTag, ".o_delete");
assert.containsN(
target,
".o_kanban_record:nth-child(2) .o_field_many2many_tags_avatar .o_tag",
2,
"should have 2 record"
);
await click(
target.querySelector(".o_kanban_record .o_field_many2many_tags_avatar img.o_m2m_avatar")
const o_kanban_record = target.querySelector(".o_kanban_record:nth-child(2)");
await click(o_kanban_record, ".o_field_tags > img.o_m2m_avatar", true);
const popover = document.querySelector(".o_popover_container");
assert.strictEqual(popover.querySelectorAll(".o_tag").length, 2, "Should have 2 tags");
// delete inside the popover
await click(popover.querySelector(".o_tag .o_delete"));
assert.strictEqual(popover.querySelectorAll(".o_tag").length, 1, "Should have 1 tag");
assert.strictEqual(
o_kanban_record.querySelectorAll(".o_tag").length,
1,
"Should have 1 tags"
);
// select input
await click(popover.querySelector(".o_field_many2many_selection input"));
// select first input
await click(popover.querySelector(".o-autocomplete--dropdown-item"));
assert.strictEqual(popover.querySelectorAll(".o_tag").length, 2, "Should have 2 tags");
assert.strictEqual(
o_kanban_record.querySelectorAll(".o_tag").length,
2,
"Should have 2 tags"
);
await click(target);
});
QUnit.test("widget many2many_tags_avatar delete tag", async function (assert) {