19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:30:27 +01:00
parent d1963a3c3a
commit 2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions

View file

@ -0,0 +1,623 @@
import { describe, expect, test } from "@odoo/hoot";
import {
click,
contains,
defineMailModels,
inputFiles,
openFormView,
start,
startServer,
triggerHotkey,
} from "@mail/../tests/mail_test_helpers";
import { advanceTime, mockDate } from "@odoo/hoot-mock";
import {
asyncStep,
mockService,
onRpc,
serverState,
waitForSteps,
} from "@web/../tests/web_test_helpers";
import { deserializeDateTime, serializeDate, today } from "@web/core/l10n/dates";
import { getOrigin } from "@web/core/utils/urls";
describe.current.tags("desktop");
defineMailModels();
test("activity upload document is available", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
const activityType = pyEnv["mail.activity.type"].find((r) => r.name === "Upload Document");
pyEnv["mail.activity"].create({
activity_category: "upload_file",
activity_type_id: activityType.id,
can_write: true,
res_id: partnerId,
res_model: "res.partner",
});
await start();
await openFormView("res.partner", partnerId);
await contains(".o-mail-Activity .btn", { text: "Upload Document" });
await contains(".btn .fa-upload");
await contains(".o-mail-Activity .o_input_file");
});
test("activity can upload a document", async () => {
const pyEnv = await startServer();
const fakeId = pyEnv["res.partner"].create({});
const activityType = pyEnv["mail.activity.type"].find((r) => r.name === "Upload Document");
pyEnv["mail.activity"].create({
activity_category: "upload_file",
activity_type_id: activityType.id,
can_write: true,
res_id: fakeId,
res_model: "res.partner",
});
await start();
await openFormView("res.partner", fakeId, {
arch: `
<form string="Fake">
<sheet></sheet>
<chatter/>
</form>`,
});
await contains(".o-mail-Activity .btn", { text: "Upload Document" });
const file = new File(["hello, world"], "text.txt", { type: "text/plain" });
await inputFiles(".o-mail-Activity .o_input_file", [file]);
});
test("activity simplest layout", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
pyEnv["mail.activity"].create({
res_id: partnerId,
res_model: "res.partner",
});
await start();
await openFormView("res.partner", partnerId);
await contains(".o-mail-Activity");
await contains(".o-mail-Activity-sidebar");
await contains(".o-mail-Activity-user");
await contains(".o-mail-Activity-note", { count: 0 });
await contains(".o-mail-Activity-details", { count: 0 });
await contains(".o-mail-Activity-mailTemplates", { count: 0 });
await contains(".btn", { count: 0, text: "Edit" });
await contains(".o-mail-Activity .btn", { count: 0, text: "Cancel" });
await contains(".btn", { count: 0, text: "Mark Done" });
await contains(".o-mail-Activity .btn", { count: 0, text: "Upload Document" });
});
test("activity with note layout", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
pyEnv["mail.activity"].create({
note: "<p>There is no good or bad note</p>",
res_id: partnerId,
res_model: "res.partner",
});
await start();
await openFormView("res.partner", partnerId);
await contains(".o-mail-Activity");
await contains(".o-mail-Activity-note", { text: "There is no good or bad note" });
});
test("activity info layout when planned after tomorrow", async () => {
mockDate("2023-01-11 12:00:00");
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
pyEnv["mail.activity"].create({
date_deadline: serializeDate(today().plus({ days: 5 })),
res_id: partnerId,
res_model: "res.partner",
state: "planned",
});
await start();
await openFormView("res.partner", partnerId);
await contains(".o-mail-Activity span.text-success", { text: "Due in 5 days:" });
});
test("activity info layout when planned tomorrow", async () => {
mockDate("2023-01-11 12:00:00");
const tomorrow = today().plus({ days: 1 });
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
pyEnv["mail.activity"].create({
date_deadline: serializeDate(tomorrow),
res_id: partnerId,
res_model: "res.partner",
state: "planned",
});
await start();
await openFormView("res.partner", partnerId);
await contains(".o-mail-Activity span.text-success", { text: "Tomorrow:" });
});
test("activity info layout when planned today", async () => {
mockDate("2023-01-11 12:00:00");
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
pyEnv["mail.activity"].create({
date_deadline: serializeDate(today()),
res_id: partnerId,
res_model: "res.partner",
state: "today",
});
await start();
await openFormView("res.partner", partnerId);
await contains(".o-mail-Activity span.text-warning", { text: "Today:" });
});
test("activity info layout when planned yesterday", async () => {
mockDate("2023-01-11 12:00:00");
const yesterday = today().plus({ days: -1 });
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
pyEnv["mail.activity"].create({
date_deadline: serializeDate(yesterday),
res_id: partnerId,
res_model: "res.partner",
state: "overdue",
});
await start();
await openFormView("res.partner", partnerId);
await contains(".o-mail-Activity span.text-danger", { text: "Yesterday:" });
});
test("activity info layout when planned before yesterday", async () => {
mockDate("2023-01-11 12:00:00");
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
pyEnv["mail.activity"].create({
date_deadline: serializeDate(today().plus({ days: -5 })),
res_id: partnerId,
res_model: "res.partner",
state: "overdue",
});
await start();
await openFormView("res.partner", partnerId);
await contains(".o-mail-Activity span.text-danger", { text: "5 days overdue:" });
});
test.skip("activity info layout change at midnight", async () => {
// skip: does not work consistently both locally and on runbot at the same time (tz issue?)
mockDate("2023-12-07 23:59:59", 0);
const tomorrow = today().plus({ days: 1 });
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
pyEnv["mail.activity"].create({
date_deadline: serializeDate(tomorrow),
res_id: partnerId,
res_model: "res.partner",
state: "planned",
});
await start();
await openFormView("res.partner", partnerId);
await contains(".o-mail-Activity span.text-success", { text: "Tomorrow:" });
mockDate("2023-12-08 00:00:01");
await advanceTime(2000);
await contains(".o-mail-Activity span.text-warning", { text: "Today:" });
});
test("activity with a summary layout", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
pyEnv["mail.activity"].create({
res_id: partnerId,
res_model: "res.partner",
summary: "test summary",
});
await start();
await openFormView("res.partner", partnerId);
await contains(".o-mail-Activity", { text: "“test summary”" });
});
test("activity without summary layout", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
pyEnv["mail.activity"].create({
activity_type_id: 1,
res_id: partnerId,
res_model: "res.partner",
});
await start();
await openFormView("res.partner", partnerId);
await contains(".o-mail-Activity", { text: "Email" });
});
test("activity details toggle", async () => {
mockDate("2023-01-11 12:00:00");
const tomorrow = today().plus({ days: 1 });
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
const userId = pyEnv["res.users"].create({ partner_id: partnerId });
pyEnv["mail.activity"].create({
create_uid: userId,
date_deadline: serializeDate(tomorrow),
res_id: partnerId,
res_model: "res.partner",
});
await start();
await openFormView("res.partner", partnerId);
await contains(".o-mail-Activity");
await contains(".o-mail-Activity-details", { count: 0 });
await contains(".o-mail-Activity i[aria-label='Info']");
await click(".o-mail-Activity i[aria-label='Info']");
await contains(".o-mail-Activity-details");
await click(".o-mail-Activity i[aria-label='Info']");
await contains(".o-mail-Activity-details", { count: 0 });
});
test("activity with mail template layout", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
const mailTemplateId = pyEnv["mail.template"].create({ name: "Dummy mail template" });
const activityType = pyEnv["mail.activity.type"].find((r) => r.name === "Email");
pyEnv["mail.activity.type"].write(activityType.id, { mail_template_ids: [mailTemplateId] });
pyEnv["mail.activity"].create({
activity_type_id: activityType.id,
res_id: partnerId,
res_model: "res.partner",
});
await start();
await openFormView("res.partner", partnerId);
await contains(".o-mail-Activity");
await contains(".o-mail-Activity-sidebar");
await contains(".o-mail-Activity-mailTemplates");
await contains(".o-mail-ActivityMailTemplate-name", { text: "Dummy mail template" });
await contains(".o-mail-ActivityMailTemplate-preview");
await contains(".o-mail-ActivityMailTemplate-send");
});
test("activity with mail template: preview mail", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
const mailTemplateId = pyEnv["mail.template"].create({ name: "Dummy mail template" });
const activityType = pyEnv["mail.activity.type"].find((r) => r.name === "Email");
pyEnv["mail.activity.type"].write(activityType.id, { mail_template_ids: [mailTemplateId] });
pyEnv["mail.activity"].create({
activity_type_id: activityType.id,
res_id: partnerId,
res_model: "res.partner",
});
mockService("action", {
doAction(action) {
if (action?.res_model !== "res.partner") {
// Click on Preview Mail Template
asyncStep("do_action");
expect(action.context.default_res_ids).toEqual([partnerId]);
expect(action.context.default_model).toBe("res.partner");
expect(action.context.default_template_id).toBe(mailTemplateId);
expect(action.type).toBe("ir.actions.act_window");
expect(action.res_model).toBe("mail.compose.message");
}
return super.doAction(...arguments);
},
});
await start();
await openFormView("res.partner", partnerId);
await contains(".o-mail-Activity");
await contains(".o-mail-ActivityMailTemplate-preview");
await click(".o-mail-ActivityMailTemplate-preview");
await waitForSteps(["do_action"]);
});
test("activity with mail template: send mail", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
const mailTemplateId = pyEnv["mail.template"].create({ name: "Dummy mail template" });
const activityType = pyEnv["mail.activity.type"].find((r) => r.name === "Email");
pyEnv["mail.activity.type"].write(activityType.id, { mail_template_ids: [mailTemplateId] });
pyEnv["mail.activity"].create({
activity_type_id: activityType.id,
res_id: partnerId,
res_model: "res.partner",
});
onRpc("res.partner", "activity_send_mail", ({ args, method }) => {
asyncStep(method);
expect(args[0]).toHaveLength(1);
expect(args[0][0]).toBe(partnerId);
expect(args[1]).toBe(mailTemplateId);
// random value returned in order for the mock server to know that this route is implemented.
return true;
});
await start();
await openFormView("res.partner", partnerId);
await contains(".o-mail-Activity");
await contains(".o-mail-ActivityMailTemplate-send");
await click(".o-mail-ActivityMailTemplate-send");
await waitForSteps(["activity_send_mail"]);
});
test("activity click on mark as done", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
const activityTypeId = pyEnv["mail.activity.type"].search([["name", "=", "Email"]])[0];
pyEnv["mail.activity"].create({
activity_category: "default",
activity_type_id: activityTypeId,
can_write: true,
res_id: partnerId,
res_model: "res.partner",
});
await start();
await openFormView("res.partner", partnerId);
await contains(".o-mail-Activity");
await click(".btn", { text: "Mark Done" });
await contains(".o-mail-ActivityMarkAsDone");
await click(".btn", { text: "Mark Done" });
await contains(".o-mail-ActivityMarkAsDone", { count: 0 });
});
test.tags("focus required");
test("activity mark as done popover should focus feedback input on open", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
const activityTypeId = pyEnv["mail.activity.type"].search([["name", "=", "Email"]])[0];
pyEnv["mail.activity"].create({
activity_category: "default",
activity_type_id: activityTypeId,
can_write: true,
res_id: partnerId,
res_model: "res.partner",
});
await start();
await openFormView("res.partner", partnerId);
await contains(".o-mail-Activity");
await click(".btn", { text: "Mark Done" });
await contains(".o-mail-ActivityMarkAsDone textarea[placeholder='Write Feedback']:focus");
});
test("activity click on edit", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
const mailTemplateId = pyEnv["mail.template"].create({ name: "Dummy mail template" });
const activityTypeId = pyEnv["mail.activity.type"].search([["name", "=", "Email"]])[0];
const activityId = pyEnv["mail.activity"].create({
activity_type_id: activityTypeId,
can_write: true,
mail_template_ids: [mailTemplateId],
res_id: partnerId,
res_model: "res.partner",
});
mockService("action", {
doAction(action) {
if (action?.res_model !== "res.partner") {
asyncStep("do_action");
expect(action.type).toBe("ir.actions.act_window");
expect(action.res_model).toBe("mail.activity");
expect(action.res_id).toBe(activityId);
}
return super.doAction(...arguments);
},
});
await start();
await openFormView("res.partner", partnerId);
await click(".o-mail-Activity .btn", { text: "Edit" });
await waitForSteps(["do_action"]);
});
test("activity click on edit should pass correct context", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
const mailTemplateId = pyEnv["mail.template"].create({ name: "Dummy mail template" });
const [activityTypeId] = pyEnv["mail.activity.type"].search([["name", "=", "Email"]]);
const activityId = pyEnv["mail.activity"].create({
activity_type_id: activityTypeId,
can_write: true,
mail_template_ids: [mailTemplateId],
res_id: partnerId,
res_model: "res.partner",
});
await start();
await openFormView("res.partner", partnerId);
mockService("action", {
async doAction(action) {
asyncStep("do_action");
expect(action.type).toBe("ir.actions.act_window");
expect(action.res_model).toBe("mail.activity");
expect(action.res_id).toBe(activityId);
expect(action.context).toEqual({
default_res_model: "res.partner",
default_res_id: partnerId,
dialog_size: "large",
});
return super.doAction(...arguments);
},
});
await click(".o-mail-Activity .btn", { text: "Edit" });
await waitForSteps(["do_action"]);
});
test("activity click on cancel", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
const activityTypeId = pyEnv["mail.activity.type"].search([["name", "=", "Email"]])[0];
const activityId = pyEnv["mail.activity"].create({
activity_type_id: activityTypeId,
can_write: true,
res_id: partnerId,
res_model: "res.partner",
});
onRpc("mail.activity", "unlink", ({ args, method }) => {
asyncStep(method);
expect(args[0]).toHaveLength(1);
expect(args[0][0]).toBe(activityId);
});
await start();
await openFormView("res.partner", partnerId);
await click(".o-mail-Activity .btn", { text: "Cancel" });
await contains(".o-mail-Activity", { count: 0 });
await waitForSteps(["unlink"]);
});
test("activity mark done popover close on ESCAPE", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
const activityTypeId = pyEnv["mail.activity.type"].search([["name", "=", "Email"]])[0];
pyEnv["mail.activity"].create({
activity_category: "default",
activity_type_id: activityTypeId,
can_write: true,
res_id: partnerId,
res_model: "res.partner",
});
await start();
await openFormView("res.partner", partnerId);
await click(".btn", { text: "Mark Done" });
await contains(".o-mail-ActivityMarkAsDone");
triggerHotkey("Escape");
await contains(".o-mail-ActivityMarkAsDone", { count: 0 });
});
test("activity mark done popover click on discard", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
const activityTypeId = pyEnv["mail.activity.type"].search([["name", "=", "Email"]])[0];
pyEnv["mail.activity"].create({
activity_category: "default",
activity_type_id: activityTypeId,
can_write: true,
res_id: partnerId,
res_model: "res.partner",
});
await start();
await openFormView("res.partner", partnerId);
await click(".btn", { text: "Mark Done" });
await click(".o-mail-ActivityMarkAsDone button", { text: "Discard" });
await contains(".o-mail-ActivityMarkAsDone", { count: 0 });
});
test("Activity are sorted by deadline", async () => {
mockDate("2023-01-11 12:00:00");
const dateBefore = today().plus({ days: -5 });
const dateAfter = today().plus({ days: 4 });
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
pyEnv["mail.activity"].create({
date_deadline: serializeDate(dateAfter),
res_id: partnerId,
res_model: "res.partner",
state: "planned",
});
pyEnv["mail.activity"].create({
date_deadline: serializeDate(today()),
res_id: partnerId,
res_model: "res.partner",
state: "today",
});
pyEnv["mail.activity"].create({
date_deadline: serializeDate(dateBefore),
res_id: partnerId,
res_model: "res.partner",
state: "overdue",
});
await start();
await openFormView("res.partner", partnerId);
await contains(":nth-child(1 of .o-mail-Activity)", { text: "5 days overdue:" });
await contains(":nth-child(2 of .o-mail-Activity)", { text: "Today:" });
await contains(":nth-child(3 of .o-mail-Activity)", { text: "Due in 4 days:" });
});
test("chatter 'activity' button open the activity schedule wizard", async () => {
const pyEnv = await startServer();
const fakeId = pyEnv["res.partner"].create({});
mockService("action", {
async doAction(action, options) {
if (action?.res_model !== "res.partner") {
asyncStep("doAction");
const expectedAction = {
context: {
active_ids: [fakeId],
active_id: fakeId,
active_model: "res.partner",
},
name: "Schedule Activity",
res_model: "mail.activity.schedule",
target: "new",
type: "ir.actions.act_window",
view_mode: "form",
views: [[false, "form"]],
};
expect(action).toEqual(expectedAction, {
message: "should execute an action with correct params",
});
options.onClose();
}
return super.doAction(...arguments);
},
});
await start();
await openFormView("res.partner", fakeId, {
arch: `
<form string="Fake">
<sheet></sheet>
<chatter/>
</form>`,
});
await click("button", { text: "Activity" });
await waitForSteps(["doAction"]);
});
test("Activity avatar should have a unique timestamp", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
pyEnv["mail.activity"].create({
res_id: partnerId,
res_model: "res.partner",
});
await start();
const partner = pyEnv["res.partner"].search_read([["id", "=", serverState.partnerId]])[0];
await openFormView("res.partner", partnerId);
await contains(".o-mail-Activity");
await contains(
`.o-mail-Activity-sidebar img[data-src="${getOrigin()}/web/image/res.partner/${
serverState.partnerId
}/avatar_128?unique=${deserializeDateTime(partner.write_date).ts}`
);
});
test("activity with a link to a record", async () => {
const pyEnv = await startServer();
const partnerId1 = pyEnv["res.partner"].create({ name: "Partner 1" });
const partnerId2 = pyEnv["res.partner"].create({ name: "Partner 2" });
pyEnv["mail.activity"].create({
note: `<p>Activity with a link to a <a href="#" data-oe-model="res.partner" data-oe-id="${partnerId2}">record</a></p>`,
res_id: partnerId1,
res_model: "res.partner",
});
await start();
await openFormView("res.partner", partnerId1);
await click(".o-mail-Activity-note a", { text: "record" });
await contains(".o_form_view input", { value: "Partner 2" });
});
test("activity with a user mention", async () => {
const pyEnv = await startServer();
const partnerId1 = pyEnv["res.partner"].create({ name: "Partner 1" });
const partnerId2 = pyEnv["res.partner"].create({ name: "Partner 2" });
pyEnv["res.users"].create({ partner_id: partnerId2 });
pyEnv["mail.activity"].create({
note: `<p>How are you, <a class="o_mail_redirect" href="#" data-oe-model="res.partner" data-oe-id="${partnerId2}">@Partner 2</a>?</p>`,
res_id: partnerId1,
res_model: "res.partner",
});
await start();
await openFormView("res.partner", partnerId1);
await click(".o-mail-Activity-note a", { text: "@Partner 2" });
await contains(".o_avatar_card:contains('Partner 2')");
});
test("activity with a channel mention", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Partner" });
const channelId = pyEnv["discuss.channel"].create({ name: "Channel", channel_type: "channel" });
pyEnv["mail.activity"].create({
note: `<p><a class="o_channel_redirect" href="#" data-oe-model="discuss.channel" data-oe-id="${channelId}">#Channel</a></p>`,
res_id: partnerId,
res_model: "res.partner",
});
await start();
await openFormView("res.partner", partnerId);
await click(".o-mail-Activity-note a", { text: "#Channel" });
await contains(".o-mail-ChatWindow-header", { text: "Channel" });
});

View file

@ -0,0 +1,200 @@
import {
click,
contains,
defineMailModels,
insertText,
openFormView,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { describe, expect, test } from "@odoo/hoot";
import { Deferred } from "@odoo/hoot-mock";
import { asyncStep, mockService, onRpc, waitForSteps } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
defineMailModels();
test("activity mark done popover simplest layout", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
pyEnv["mail.activity"].create({
activity_category: "not_upload_file",
can_write: true,
res_id: partnerId,
res_model: "res.partner",
});
await start();
await openFormView("res.partner", partnerId);
await click(".btn", { text: "Mark Done" });
await contains(".o-mail-ActivityMarkAsDone");
await contains(".o-mail-ActivityMarkAsDone textarea[placeholder='Write Feedback']");
await contains(".o-mail-ActivityMarkAsDone button[aria-label='Done and Schedule Next']");
await contains(".o-mail-ActivityMarkAsDone button[aria-label='Done']");
await contains(".o-mail-ActivityMarkAsDone button", { text: "Discard" });
});
test("activity with force next mark done popover simplest layout", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
const activityTypeId = pyEnv["mail.activity.type"].create({
name: "TriggerType",
chaining_type: "trigger",
});
pyEnv["mail.activity"].create({
activity_category: "not_upload_file",
activity_type_id: activityTypeId,
can_write: true,
res_id: partnerId,
res_model: "res.partner",
});
await start();
await openFormView("res.partner", partnerId);
await click(".btn", { text: "Mark Done" });
await contains(".o-mail-ActivityMarkAsDone");
await contains(".o-mail-ActivityMarkAsDone textarea[placeholder='Write Feedback']");
await contains(".o-mail-ActivityMarkAsDone button[aria-label='Done and Schedule Next']");
await contains(".o-mail-ActivityMarkAsDone button[aria-label='Done']", { count: 0 });
await contains(".o-mail-ActivityMarkAsDone button", { text: "Discard" });
});
test("activity mark done popover mark done without feedback", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
const activityId = pyEnv["mail.activity"].create({
activity_category: "not_upload_file",
can_write: true,
res_id: partnerId,
res_model: "res.partner",
});
onRpc("mail.activity", "action_feedback", ({ args, kwargs }) => {
asyncStep("action_feedback");
expect(args).toHaveLength(1);
expect(args[0]).toHaveLength(1);
expect(args[0][0]).toBe(activityId);
expect(kwargs.attachment_ids).toBeEmpty();
expect(kwargs).not.toInclude("feedback");
// random value returned in order for the mock server to know that this route is implemented.
return true;
});
await start();
await openFormView("res.partner", partnerId);
await click(".btn", { text: "Mark Done" });
await click(".o-mail-ActivityMarkAsDone button[aria-label='Done']");
await waitForSteps(["action_feedback"]);
});
test("activity mark done popover mark done with feedback", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
const activityId = pyEnv["mail.activity"].create({
activity_category: "not_upload_file",
can_write: true,
res_id: partnerId,
res_model: "res.partner",
});
onRpc("mail.activity", "action_feedback", ({ args, kwargs, method }) => {
asyncStep(method);
expect(args).toHaveLength(1);
expect(args[0]).toHaveLength(1);
expect(args[0][0]).toBe(activityId);
expect(kwargs.attachment_ids).toBeEmpty();
expect(kwargs.feedback).toBe("This task is done");
// random value returned in order for the mock server to know that this route is implemented.
return true;
});
onRpc("mail.activity", "unlink", () => {
// 'unlink' on non-existing record raises a server crash
throw new Error(
"'unlink' RPC on activity must not be called (already unlinked from mark as done)"
);
});
await start();
await openFormView("res.partner", partnerId);
await click(".btn", { text: "Mark Done" });
await insertText(
".o-mail-ActivityMarkAsDone textarea[placeholder='Write Feedback']",
"This task is done"
);
await click(".o-mail-ActivityMarkAsDone button[aria-label='Done']");
await waitForSteps(["action_feedback"]);
});
test("activity mark done popover mark done and schedule next", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
const activityId = pyEnv["mail.activity"].create({
activity_category: "not_upload_file",
can_write: true,
res_id: partnerId,
res_model: "res.partner",
});
onRpc("mail.activity", "action_feedback_schedule_next", ({ args, kwargs, method }) => {
asyncStep(method);
expect(args).toHaveLength(1);
expect(args[0]).toHaveLength(1);
expect(args[0][0]).toBe(activityId);
expect(kwargs.feedback).toBe("This task is done");
return false;
});
onRpc("mail.activity", "unlink", () => {
// 'unlink' on non-existing record raises a server crash
throw new Error(
"'unlink' RPC on activity must not be called (already unlinked from mark as done)"
);
});
mockService("action", {
doAction(action) {
if (action?.res_model !== "res.partner") {
asyncStep("activity_action");
throw new Error(
"The do-action event should not be triggered when the route doesn't return an action"
);
}
return super.doAction(...arguments);
},
});
await start();
await openFormView("res.partner", partnerId);
await click(".btn", { text: "Mark Done" });
await insertText(
".o-mail-ActivityMarkAsDone textarea[placeholder='Write Feedback']",
"This task is done"
);
await click(".o-mail-ActivityMarkAsDone button[aria-label='Done and Schedule Next']");
await waitForSteps(["action_feedback_schedule_next"]);
});
test("[technical] activity mark done & schedule next with new action", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
pyEnv["mail.activity"].create({
activity_category: "not_upload_file",
can_write: true,
res_id: partnerId,
res_model: "res.partner",
});
onRpc("mail.activity", "action_feedback_schedule_next", () => ({
type: "ir.actions.act_window",
}));
const def = new Deferred();
mockService("action", {
doAction(action) {
if (action?.res_model !== "res.partner") {
def.resolve();
asyncStep("activity_action");
expect(action).toEqual(
{ type: "ir.actions.act_window" },
{ message: "The content of the action should be correct" }
);
return;
}
return super.doAction(...arguments);
},
});
await start();
await openFormView("res.partner", partnerId);
await click(".btn", { text: "Mark Done" });
await click(".o-mail-ActivityMarkAsDone button[aria-label='Done and Schedule Next']");
await def;
await waitForSteps(["activity_action"]);
});

View file

@ -0,0 +1,42 @@
import {
click,
contains,
defineMailModels,
start,
startServer,
triggerHotkey,
} from "@mail/../tests/mail_test_helpers";
import { ActivityMenu } from "@mail/core/web/activity_menu";
import { describe, expect, test } from "@odoo/hoot";
import { animationFrame, queryText } from "@odoo/hoot-dom";
import { mountWithCleanup } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
defineMailModels();
test("should update activities when opening the activity menu", async () => {
const pyEnv = await startServer();
await start();
await contains(".o_menu_systray i[aria-label='Activities']");
await contains(".o-mail-ActivityMenu-counter", { count: 0 });
const partnerId = pyEnv["res.partner"].create({});
pyEnv["mail.activity"].create({
res_id: partnerId,
res_model: "res.partner",
});
await click(".o_menu_systray i[aria-label='Activities']");
await contains(".o-mail-ActivityMenu-counter", { text: "1" });
});
test("global shortcut", async () => {
await mountWithCleanup(ActivityMenu);
await triggerHotkey("control+k");
await animationFrame();
expect(queryText(`.o_command:contains("Activity") .o_command_hotkey`)).toEqual(
"Activity\nALT + SHIFT + A",
{ message: "The command should be registered with the right hotkey" }
);
await triggerHotkey("alt+shift+a");
await animationFrame();
expect(".modal-dialog .modal-title").toHaveText("Schedule Activity");
});

View file

@ -0,0 +1,584 @@
import { describe, expect, test } from "@odoo/hoot";
import { leave, runAllTimers } from "@odoo/hoot-dom";
import { Command, serverState, withUser } from "@web/../tests/web_test_helpers";
import {
assertChatHub,
click,
contains,
defineMailModels,
hover,
insertText,
onRpcBefore,
openDiscuss,
openFormView,
setupChatHub,
start,
startServer,
triggerEvents,
triggerHotkey,
} from "../mail_test_helpers";
import { rpc } from "@web/core/network/rpc";
import { range } from "@web/core/utils/numbers";
describe.current.tags("desktop");
defineMailModels();
test("Folded chat windows are displayed as chat bubbles", async () => {
const pyEnv = await startServer();
const channelIds = pyEnv["discuss.channel"].create([
{ name: "Channel A" },
{ name: "Channel B" },
]);
setupChatHub({ folded: channelIds });
await start();
await contains(".o-mail-ChatBubble", { count: 2 });
await click(".o-mail-ChatBubble", { count: 2 });
await contains(".o-mail-ChatBubble", { count: 1 });
await contains(".o-mail-ChatWindow", { count: 1 });
});
test.tags("focus required");
test("No duplicated chat bubbles", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "John" });
pyEnv["res.users"].create({ partner_id: partnerId });
await start();
// Make bubble of "John" chat
await click(".o_menu_systray i[aria-label='Messages']");
await click(".o-mail-MessagingMenu button", { text: "New Message" });
await contains(".o_command_name", { count: 5 });
await insertText("input[placeholder='Search a conversation']", "John");
await contains(".o_command_name", { count: 3 });
await click(".o_command_name", { text: "John" });
await contains(".o-mail-ChatWindow", { text: "John" });
await contains(".o-mail-ChatWindow", {
text: "This is the start of your direct chat with John",
}); // wait fully loaded
await click("button[title='Fold']");
await contains(".o-mail-ChatBubble[name='John']");
// Make bubble of "John" chat again
await click(".o_menu_systray i[aria-label='Messages']");
await click(".o-mail-MessagingMenu button", { text: "New Message" });
await contains(".o_command_name", { count: 5 });
await insertText("input[placeholder='Search a conversation']", "John");
await contains(".o_command_name", { count: 3 });
await click(".o_command_name", { text: "John" });
await contains(".o-mail-ChatBubble[name='John']", { count: 0 });
await contains(".o-mail-ChatWindow", { text: "John" });
await click(".o-mail-ChatWindow-header [title='Fold']");
// Make again from click messaging menu item
await click(".o_menu_systray i[aria-label='Messages']");
await click(".o-mail-NotificationItem");
await contains(".o-mail-ChatBubble[name='John']", { count: 0 });
await contains(".o-mail-ChatWindow", { text: "John" });
});
test("Up to 7 chat bubbles", async () => {
const pyEnv = await startServer();
const channelIds = [];
for (let i = 1; i <= 8; i++) {
channelIds.push(pyEnv["discuss.channel"].create({ name: String(i) }));
}
setupChatHub({ folded: channelIds.reverse() });
await start();
for (let i = 8; i > 1; i--) {
await contains(`.o-mail-ChatBubble[name='${String(i)}']`);
}
await contains(".o-mail-ChatBubble[name='1']", { count: 0 });
await contains(".o-mail-ChatHub-hiddenBtn", { text: "+1" });
await hover(".o-mail-ChatHub-hiddenBtn");
await contains(".o-mail-ChatHub-hiddenItem[name='1']");
await contains(".o-mail-ChatWindow", { count: 0 });
await click(".o-mail-ChatHub-hiddenItem");
await contains(".o-mail-ChatWindow", { count: 1 });
await contains(".o-mail-ChatHub-hiddenBtn", { count: 0 });
});
test("Ordering of chat bubbles is consistent and seems logical.", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Demo" });
const userId = pyEnv["res.users"].create({ partner_id: partnerId });
const channelId = pyEnv["discuss.channel"].create({
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
channel_type: "chat",
});
const channelIds = [channelId];
for (let i = 1; i <= 7; i++) {
channelIds.push(pyEnv["discuss.channel"].create({ name: String(i) }));
}
setupChatHub({ folded: channelIds.reverse() });
await start();
// FIXME: expect arbitrary order 7, 6, 5, 4, 3, 2, 1
await contains(":nth-child(1 of .o-mail-ChatBubble)[name='7']");
await contains(":nth-child(2 of .o-mail-ChatBubble)[name='6']");
await contains(":nth-child(3 of .o-mail-ChatBubble)[name='5']");
await contains(":nth-child(4 of .o-mail-ChatBubble)[name='4']");
await contains(":nth-child(5 of .o-mail-ChatBubble)[name='3']");
await contains(":nth-child(6 of .o-mail-ChatBubble)[name='2']");
await contains(":nth-child(7 of .o-mail-ChatBubble)[name='1']");
await contains(".o-mail-ChatBubble[name='Demo']", { count: 0 });
await contains(".o-mail-ChatWindow", { count: 0 });
await click(".o-mail-ChatBubble[name='3']");
await contains(".o-mail-ChatWindow", { text: "3" });
await contains(":nth-child(7 of .o-mail-ChatBubble)[name='Demo']");
await click(".o-mail-ChatWindow-header [title='Fold']");
await contains(".o-mail-ChatBubble[name='Demo']", { count: 0 });
await click(".o-mail-ChatBubble[name='4']");
await contains(":nth-child(1 of .o-mail-ChatBubble)[name='3']");
await contains(":nth-child(2 of .o-mail-ChatBubble)[name='7']");
await contains(":nth-child(3 of .o-mail-ChatBubble)[name='6']");
await contains(":nth-child(7 of .o-mail-ChatBubble)[name='Demo']");
await click(".o-mail-ChatWindow-header [title='Fold']");
await contains(".o-mail-ChatWindow", { count: 0 });
// no reorder on receiving new message
withUser(userId, () =>
rpc("/mail/message/post", {
post_data: { body: "test", message_type: "comment" },
thread_id: channelId,
thread_model: "discuss.channel",
})
);
await hover(".o-mail-ChatHub-hiddenBtn");
await contains(".o-mail-ChatHub-hiddenItem[name='Demo']");
});
test("Hover on chat bubble shows chat name + last message preview", async () => {
const pyEnv = await startServer();
const marcPartnerId = pyEnv["res.partner"].create({ name: "Marc" });
const marcChannelId = pyEnv["discuss.channel"].create({
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: marcPartnerId }),
],
channel_type: "chat",
});
pyEnv["mail.message"].create({
body: "Hello!",
model: "discuss.channel",
author_id: marcPartnerId,
res_id: marcChannelId,
});
const demoPartnerId = pyEnv["res.partner"].create({ name: "Demo" });
const demoChannelId = pyEnv["discuss.channel"].create({
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: demoPartnerId }),
],
channel_type: "chat",
});
setupChatHub({ folded: [marcChannelId, demoChannelId] });
await start();
await hover(".o-mail-ChatBubble[name='Marc']");
await contains(".o-mail-ChatBubble-preview", { text: "MarcHello!" });
await leave();
await contains(".o-mail-ChatBubble-preview", { count: 0 });
await hover(".o-mail-ChatBubble[name='Demo']");
await contains(".o-mail-ChatBubble-preview", { text: "Demo" });
await leave();
rpc("/mail/message/post", {
post_data: { body: "Hi", message_type: "comment" },
thread_id: demoChannelId,
thread_model: "discuss.channel",
});
await hover(".o-mail-ChatBubble[name='Demo']");
await contains(".o-mail-ChatBubble-preview", { text: "DemoYou: Hi" });
});
test("Chat bubble preview works on author as email address", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["discuss.channel"].create({ name: "test channel" });
const messageId = pyEnv["mail.message"].create({
author_id: null,
body: "Some email message",
email_from: "md@oilcompany.fr",
model: "discuss.channel",
needaction: true,
res_id: partnerId,
});
pyEnv["mail.notification"].create({
mail_message_id: messageId,
notification_status: "sent",
notification_type: "inbox",
res_partner_id: serverState.partnerId,
});
await start();
await click(".o_menu_systray i[aria-label='Messages']");
await click(".o-mail-NotificationItem");
await click(".o-mail-ChatWindow [title='Fold']");
await hover(".o-mail-ChatBubble");
await contains(".o-mail-ChatBubble-preview", { text: "md@oilcompany.fr: Some email message" });
});
test("chat bubbles are synced between tabs", async () => {
const pyEnv = await startServer();
const marcPartnerId = pyEnv["res.partner"].create({ name: "Marc" });
const channelId = pyEnv["discuss.channel"].create({
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: marcPartnerId }),
],
channel_type: "chat",
});
setupChatHub({ folded: [channelId] });
const tab1 = await start({ asTab: true });
const tab2 = await start({ asTab: true });
await contains(`${tab1.selector} .o-mail-ChatBubble`);
await contains(`${tab2.selector} .o-mail-ChatBubble`);
await runAllTimers(); // Wait for bus service to fully load
await click(`${tab1.selector} .o-mail-ChatBubble[name='Marc']`);
await contains(`${tab2.selector} .o-mail-ChatWindow`); // open sync
await click(`${tab2.selector} .o-mail-ChatWindow-header [title='Fold']`);
await contains(`${tab1.selector} .o-mail-ChatWindow`, { count: 0 }); // fold sync
await click(`${tab1.selector} .o-mail-ChatBubble[name='Marc'] .o-mail-ChatBubble-close`);
await contains(`${tab2.selector} .o-mail-ChatBubble[name='Marc']`, { count: 0 }); // close sync
});
test("Chat bubbles do not fetch messages until becoming open", async () => {
const pyEnv = await startServer();
const [channeId1, channelId2] = pyEnv["discuss.channel"].create([
{ name: "Orange" },
{ name: "Apple" },
]);
pyEnv["mail.message"].create([
{
body: "Orange",
res_id: channeId1,
message_type: "comment",
model: "discuss.channel",
},
{
body: "Apple",
res_id: channelId2,
message_type: "comment",
model: "discuss.channel",
},
]);
onRpcBefore("/discuss/channel/messages", () => expect.step("fetch_messages"));
setupChatHub({ folded: [channeId1, channelId2] });
await start();
await contains(".o-mail-ChatBubble[name='Orange']");
expect.verifySteps([]);
await click(".o-mail-ChatBubble[name='Orange']");
await contains(".o-mail-ChatWindow");
await contains(".o-mail-Message-content", { text: "Orange" });
await contains(".o-mail-Message-content", { count: 0, text: "Apple" });
expect.verifySteps(["fetch_messages"]); // from "Orange" becoming open
});
test("More than 7 actually folded chat windows shows a 'hidden' chat bubble menu", async () => {
const pyEnv = await startServer();
const channelIds = [];
for (let i = 1; i <= 8; i++) {
channelIds.push(pyEnv["discuss.channel"].create({ name: String(i) }));
}
setupChatHub({ folded: channelIds.reverse() });
await start();
// Can make chat from hidden menu
await hover(".o-mail-ChatHub-hiddenBtn");
await click(".o-mail-ChatHub-hiddenItem");
await leave(); // FIXME: hover is persistent otherwise
await contains(".o-mail-ChatHub-hiddenItem", { count: 0 });
await contains(".o-mail-ChatHub-hiddenBtn", { count: 0 });
await contains(".o-mail-ChatWindow");
await click(".o-mail-ChatWindow-header [title='Fold']");
// Can open hidden chat from messaging menu
await click("i[aria-label='Messages']");
await click(".o-mail-NotificationItem", { text: "2" });
await contains(".o-mail-ChatHub-hiddenItem", { count: 0 });
await contains(".o-mail-ChatHub-hiddenBtn", { count: 0 });
await contains(".o-mail-ChatWindow");
await click(".o-mail-ChatWindow-header [title='Fold']");
// Can close chat from hidden menu.
await hover(".o-mail-ChatHub-hiddenBtn");
await hover(".o-mail-ChatHub-hiddenItem");
await click(".o-mail-ChatHub-hiddenClose");
await contains(".o-mail-ChatHub-hiddenItem", { count: 0 });
await contains(".o-mail-ChatHub-hiddenBtn", { count: 0 });
await contains(".o-mail-ChatWindow", { count: 0 });
});
test("Can close all chat windows at once", async () => {
const pyEnv = await startServer();
const channelIds = pyEnv["discuss.channel"].create(
Array(20)
.keys()
.map((i) => ({ name: String(i) }))
);
setupChatHub({ folded: channelIds.reverse() });
await start();
await contains(".o-mail-ChatBubble", { count: 8 }); // max reached
await contains(".o-mail-ChatBubble", { text: "+13" });
await hover(".o-mail-ChatHub-hiddenBtn");
await click("button[title='Chat Options']");
await click(".o-dropdown-item", { text: "Close all conversations" });
await contains(".o-mail-ChatBubble", { count: 0 });
assertChatHub({});
});
test("Don't show chat hub in discuss app", async () => {
const pyEnv = await startServer();
const channelIds = pyEnv["discuss.channel"].create(
range(0, 20).map((i) => ({ name: String(i) }))
);
setupChatHub({ folded: channelIds.reverse() });
await start();
await contains(".o-mail-ChatBubble", { count: 8 }); // max reached
await contains(".o-mail-ChatBubble", { text: "+13" });
await openDiscuss();
await contains(".o-mail-ChatBubble", { count: 0 });
});
test("Can compact chat hub", async () => {
// allows to temporarily reduce footprint of chat windows on UI
const pyEnv = await startServer();
const channelIds = [];
for (let i = 1; i <= 20; i++) {
channelIds.push(pyEnv["discuss.channel"].create({ name: String(i) }));
}
setupChatHub({ folded: channelIds.reverse() });
await start();
await contains(".o-mail-ChatBubble", { count: 8 }); // max reached
await contains(".o-mail-ChatBubble", { text: "+13" });
await hover(".o-mail-ChatHub-hiddenBtn");
await click("button[title='Chat Options']");
await click(".o-dropdown-item", { text: "Hide all conversations" });
await contains(".o-mail-ChatBubble i.fa.fa-comments");
await click(".o-mail-ChatBubble i.fa.fa-comments");
await contains(".o-mail-ChatBubble", { count: 8 });
// alternative compact: click hidden button
await click(".o-mail-ChatBubble", { text: "+13" });
await contains(".o-mail-ChatBubble i.fa.fa-comments");
// don't show compact button in discuss app
await openDiscuss();
await contains(".o-mail-Discuss[data-active]");
await contains(".o-mail-ChatBubble i.fa.fa-comments", { count: 0 });
});
test("Compact chat hub is crosstab synced", async () => {
const pyEnv = await startServer();
const channelIds = pyEnv["discuss.channel"].create([{ name: "ch-1" }, { name: "ch-2" }]);
setupChatHub({ folded: channelIds });
const env1 = await start({ asTab: true });
const env2 = await start({ asTab: true });
await contains(`${env1.selector} .o-mail-ChatBubble`, { count: 2 });
await contains(`${env2.selector} .o-mail-ChatBubble`, { count: 2 });
await hover(`${env1.selector} .o-mail-ChatBubble:eq(0)`);
await click(`${env1.selector} button[title='Chat Options']`);
await click(`${env1.selector} .o-dropdown-item`, { text: "Hide all conversations" });
await contains(`${env1.selector} .o-mail-ChatBubble .fa-comments`);
await contains(`${env2.selector} .o-mail-ChatBubble .fa-comments`);
});
test("Compacted chat hub shows badge with amount of hidden chats with important messages", async () => {
const pyEnv = await startServer();
const channelIds = [];
for (let i = 1; i <= 20; i++) {
const partner_id = pyEnv["res.partner"].create({ name: `partner_${i}` });
const chatId = pyEnv["discuss.channel"].create({
name: String(i),
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id }),
],
channel_type: "chat",
});
channelIds.push(chatId);
if (i < 10) {
pyEnv["mail.message"].create({
body: "Hello!",
model: "discuss.channel",
author_id: partner_id,
res_id: chatId,
});
}
}
setupChatHub({ folded: channelIds });
await start();
await contains(".o-mail-ChatBubble", { count: 8 }); // max reached
await contains(".o-mail-ChatBubble", { text: "+13" });
await click(".o-mail-ChatHub-hiddenBtn");
await contains(".o-mail-ChatBubble i.fa.fa-comments");
await contains(".o-mail-ChatBubble .o-discuss-badge", { text: "9" });
});
test("Show IM status", async () => {
const pyEnv = await startServer();
const demoId = pyEnv["res.partner"].create({ name: "Demo User", im_status: "online" });
const channelId = pyEnv["discuss.channel"].create({
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: demoId }),
],
channel_type: "chat",
});
setupChatHub({ folded: [channelId] });
await start();
await contains(".o-mail-ChatBubble .fa-circle.text-success[aria-label='User is online']");
});
test("Attachment-only message preview shows file name", async () => {
const pyEnv = await startServer();
const [partner1, partner2, partner3] = pyEnv["res.partner"].create([
{ name: "Partner1" },
{ name: "Partner2" },
{ name: "Partner3" },
]);
const [channel1, channel2, channel3] = pyEnv["discuss.channel"].create([
{
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partner1 }),
],
channel_type: "chat",
},
{
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partner2 }),
],
channel_type: "chat",
},
{
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partner3 }),
],
channel_type: "chat",
},
]);
pyEnv["mail.message"].create([
{
attachment_ids: [
Command.create({
mimetype: "application/pdf",
name: "File.pdf",
res_id: channel1,
res_model: "discuss.channel",
}),
],
author_id: partner1,
body: "",
model: "discuss.channel",
res_id: channel1,
},
{
attachment_ids: [
Command.create({
mimetype: "image/jpeg",
name: "Image.jpeg",
res_id: channel2,
res_model: "discuss.channel",
}),
Command.create({
mimetype: "application/pdf",
name: "File.pdf",
res_id: channel2,
res_model: "discuss.channel",
}),
],
author_id: partner2,
body: "",
model: "discuss.channel",
res_id: channel2,
},
{
attachment_ids: [
Command.create({
mimetype: "application/pdf",
name: "File.pdf",
res_id: channel3,
res_model: "discuss.channel",
}),
Command.create({
mimetype: "image/jpeg",
name: "Image.jpeg",
res_id: channel3,
res_model: "discuss.channel",
}),
Command.create({
mimetype: "video/mp4",
name: "Video.mp4",
res_id: channel3,
res_model: "discuss.channel",
}),
],
author_id: partner3,
body: "",
model: "discuss.channel",
res_id: channel3,
},
]);
setupChatHub({ folded: [channel1, channel2, channel3] });
await start();
await contains(".o-mail-ChatBubble[name='Partner1']");
await hover(".o-mail-ChatBubble[name='Partner1']");
await contains(".o-mail-ChatBubble-preview", { text: "Partner1File.pdf" });
await contains(".o-mail-ChatBubble[name='Partner2']");
await hover(".o-mail-ChatBubble[name='Partner2']");
await contains(".o-mail-ChatBubble-preview", { text: "Partner2Image.jpeg and File.pdf" });
await contains(".o-mail-ChatBubble[name='Partner3']");
await hover(".o-mail-ChatBubble[name='Partner3']");
await contains(".o-mail-ChatBubble-preview", {
text: "Partner3File.pdf and 2 other attachments",
});
});
test("Open chat window from messaging menu with chat hub compact", async () => {
const pyEnv = await startServer();
const johnId = pyEnv["res.users"].create({ name: "John" });
const johnPartnerId = pyEnv["res.partner"].create({ user_ids: [johnId], name: "John" });
const chatId = pyEnv["discuss.channel"].create({
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: johnPartnerId }),
],
channel_type: "chat",
});
setupChatHub({ folded: [chatId] });
await start();
await openFormView("res.partner", serverState.partnerId);
await click("button[title='Chat Options']");
await click(".o-dropdown-item", { text: "Hide all conversations" });
await contains(".o-mail-ChatHub-compact");
await click(".o_menu_systray i[aria-label='Messages']");
await click(".o-mail-NotificationItem", { text: "John" });
await contains(".o-mail-ChatWindow", { text: "John" });
await triggerEvents(".o-mail-Composer-input", ["blur", "focusout"]); // FIXME: click fold doesn't focusout/blur the composer, thus marks as read
await click(".o-mail-ChatWindow-header [title='Fold']");
await contains(".o-mail-ChatWindow", { count: 0 });
await withUser(johnId, () =>
rpc("/mail/message/post", {
post_data: { body: "Hello Mitchel!", message_type: "comment" },
thread_id: chatId,
thread_model: "discuss.channel",
})
);
await contains(".o-mail-ChatHub-compact", { text: "1" });
await contains(".o-mail-ChatWindow", { count: 0 });
});
test("Open chat window from command palette with chat hub compact", async () => {
const pyEnv = await startServer();
const johnId = pyEnv["res.users"].create({ name: "John" });
const johnPartnerId = pyEnv["res.partner"].create({ user_ids: [johnId], name: "John" });
const chatId = pyEnv["discuss.channel"].create({
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: johnPartnerId }),
],
channel_type: "chat",
});
setupChatHub({ folded: [chatId] });
await start();
await click("button[title='Chat Options']");
await click(".o-dropdown-item", { text: "Hide all conversations" });
await contains(".o-mail-ChatHub-compact");
await triggerHotkey("control+k");
await insertText(".o_command_palette_search input", "@");
await click(".o-mail-DiscussCommand", { text: "John" });
await contains(".o-mail-ChatWindow", { text: "John" });
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,125 @@
import {
click,
contains,
defineMailModels,
onRpcBefore,
patchUiSize,
setupChatHub,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { CHAT_HUB_KEY } from "@mail/core/common/chat_hub_model";
import { describe, expect, test } from "@odoo/hoot";
import { asyncStep, getService, waitForSteps } from "@web/../tests/web_test_helpers";
import { browser } from "@web/core/browser/browser";
describe.current.tags("desktop");
defineMailModels();
test("chat window does not fetch messages if hidden", async () => {
const pyEnv = await startServer();
const [channeId1, channelId2, channelId3] = pyEnv["discuss.channel"].create([{}, {}, {}]);
pyEnv["mail.message"].create([
{
body: "Orange",
res_id: channeId1,
message_type: "comment",
model: "discuss.channel",
},
{
body: "Apple",
res_id: channelId2,
message_type: "comment",
model: "discuss.channel",
},
{
body: "Banana",
res_id: channelId3,
message_type: "comment",
model: "discuss.channel",
},
]);
patchUiSize({ width: 900 }); // enough for 2 open chat windows max
onRpcBefore("/discuss/channel/messages", () => asyncStep("fetch_messages"));
setupChatHub({ opened: [channelId3, channelId2, channeId1] });
await start();
await contains(".o-mail-ChatWindow", { count: 2 });
await contains(".o-mail-ChatBubble", { count: 1 });
// FIXME: expected ordering: Banana, Apple, Orange
await contains(".o-mail-Message-content", { text: "Banana" });
await contains(".o-mail-Message-content", { text: "Apple" });
await contains(".o-mail-Message-content", { count: 0, text: "Orange" });
await waitForSteps(["fetch_messages", "fetch_messages"]);
});
test("click on hidden chat window should fetch its messages", async () => {
const pyEnv = await startServer();
const [channeId1, channelId2, channelId3] = pyEnv["discuss.channel"].create([{}, {}, {}]);
pyEnv["mail.message"].create([
{
body: "Orange",
res_id: channeId1,
message_type: "comment",
model: "discuss.channel",
},
{
body: "Apple",
res_id: channelId2,
message_type: "comment",
model: "discuss.channel",
},
{
body: "Banana",
res_id: channelId3,
message_type: "comment",
model: "discuss.channel",
},
]);
patchUiSize({ width: 900 }); // enough for 2 open chat windows max
onRpcBefore("/discuss/channel/messages", () => asyncStep("fetch_messages"));
setupChatHub({ opened: [channelId3, channelId2, channeId1] });
await start();
await contains(".o-mail-ChatWindow", { count: 2 });
await contains(".o-mail-ChatBubble", { count: 1 });
// FIXME: expected ordering: Banana, Apple, Orange
await contains(".o-mail-Message-content", { text: "Banana" });
await contains(".o-mail-Message-content", { text: "Apple" });
await contains(".o-mail-Message-content", { count: 0, text: "Orange" });
await waitForSteps(["fetch_messages", "fetch_messages"]);
await click(".o-mail-ChatBubble");
await contains(".o-mail-Message-content", { text: "Orange" });
await contains(".o-mail-Message-content", { text: "Banana" });
await contains(".o-mail-Message", { count: 0, text: "Apple" });
await waitForSteps(["fetch_messages"]);
});
test("downgrade 19.1 to 19.0 should ignore chat hub local storage data", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
// simulate data in local storage like 19.1
browser.localStorage.setItem(
CHAT_HUB_KEY,
JSON.stringify({
opened: [{ id: channelId }],
folded: [{ id: 1000 }],
})
);
await start();
const store = getService("mail.store");
await store.chatHub.initPromise;
expect(browser.localStorage.getItem(CHAT_HUB_KEY)).toBe(null);
await contains(".o-mail-ChatHub");
await contains(".o-mail-ChatHub .o-mail-ChatWindow", { count: 0 });
await contains(".o-mail-ChatHub .o-mail-ChatBubble", { count: 0 });
await click(".o_menu_systray i[aria-label='Messages']");
await click(".o-mail-NotificationItem");
await contains(".o-mail-ChatWindow");
expect(browser.localStorage.getItem(CHAT_HUB_KEY)).toBe(
JSON.stringify({ opened: [{ id: channelId, model: "discuss.channel" }], folded: [] })
);
await click(".o-mail-ChatWindow-header [title='Fold']");
await contains(".o-mail-ChatBubble");
expect(browser.localStorage.getItem(CHAT_HUB_KEY)).toBe(
JSON.stringify({ opened: [], folded: [{ id: channelId, model: "discuss.channel" }] })
);
});

View file

@ -0,0 +1,209 @@
import {
SIZES,
click,
contains,
defineMailModels,
openFormView,
patchUiSize,
scroll,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { describe, test } from "@odoo/hoot";
describe.current.tags("desktop");
defineMailModels();
test("base non-empty rendering", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
pyEnv["ir.attachment"].create([
{
mimetype: "text/plain",
name: "Blah.txt",
res_id: partnerId,
res_model: "res.partner",
},
{
mimetype: "text/plain",
name: "Blu.txt",
res_id: partnerId,
res_model: "res.partner",
},
]);
await start();
await openFormView("res.partner", partnerId, {
arch: `
<form>
<sheet></sheet>
<chatter open_attachments="True"/>
</form>`,
});
await contains(".o-mail-AttachmentBox");
await contains("button", { text: "Attach files" });
await contains(".o-mail-Chatter input[type='file']");
await contains(".o-mail-AttachmentList");
});
test("remove attachment should ask for confirmation", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
pyEnv["ir.attachment"].create({
mimetype: "text/plain",
name: "Blah.txt",
res_id: partnerId,
res_model: "res.partner",
});
await start();
await openFormView("res.partner", partnerId, {
arch: `
<form>
<sheet></sheet>
<chatter open_attachments="True"/>
</form>`,
});
await contains(".o-mail-AttachmentCard");
await contains("button[title='Remove']");
await click("button[title='Remove']");
await contains(".modal-body", { text: 'Do you really want to delete "Blah.txt"?' });
// Confirm the deletion
await click(".modal-footer .btn-primary");
await contains(".o-mail-AttachmentImage", { count: 0 });
});
test("view attachments", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
pyEnv["ir.attachment"].create([
{
mimetype: "text/plain",
name: "Blah.txt",
res_id: partnerId,
res_model: "res.partner",
},
{
mimetype: "text/plain",
name: "Blu.txt",
res_id: partnerId,
res_model: "res.partner",
},
]);
await start();
await openFormView("res.partner", partnerId, {
arch: `
<form>
<sheet></sheet>
<chatter open_attachments="True"/>
</form>`,
});
await click('.o-mail-AttachmentContainer[aria-label="Blah.txt"] .o-mail-AttachmentCard-image');
await contains(".o-FileViewer");
await contains(".o-FileViewer-header", { text: "Blah.txt" });
await contains(".o-FileViewer div[aria-label='Next']");
await click(".o-FileViewer div[aria-label='Next']");
await contains(".o-FileViewer-header", { text: "Blu.txt" });
await contains(".o-FileViewer div[aria-label='Next']");
await click(".o-FileViewer div[aria-label='Next']");
await contains(".o-FileViewer-header", { text: "Blah.txt" });
});
test("scroll to attachment box when toggling on", async () => {
patchUiSize({ size: SIZES.XXL });
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
for (let i = 0; i < 30; i++) {
pyEnv["mail.message"].create({
body: "not empty".repeat(50),
model: "res.partner",
res_id: partnerId,
});
}
pyEnv["ir.attachment"].create({
mimetype: "text/plain",
name: "Blah.txt",
res_id: partnerId,
res_model: "res.partner",
});
await start();
await openFormView("res.partner", partnerId);
await contains(".o-mail-Message", { count: 30 });
await scroll(".o-mail-Chatter", "bottom");
await click("button[aria-label='Attach files']");
await contains(".o-mail-AttachmentBox");
await contains(".o-mail-Chatter", { scroll: 0 });
await contains(".o-mail-AttachmentBox", { visible: true });
});
test("do not auto-scroll to attachment box when initially open", async () => {
patchUiSize({ size: SIZES.LG });
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
pyEnv["mail.message"].create({
body: "not empty",
model: "res.partner",
res_id: partnerId,
});
pyEnv["ir.attachment"].create({
mimetype: "text/plain",
name: "Blah.txt",
res_id: partnerId,
res_model: "res.partner",
});
await start();
await openFormView("res.partner", partnerId, {
arch: `
<form>
${`<sheet><field name="name"/></sheet>`.repeat(100)}
<chatter open_attachments="True"/>
</form>`,
});
await contains(".o-mail-Message");
// weak test, no guarantee that we waited long enough for the potential scroll to happen
await contains(".o_content", { scroll: 0 });
});
test("attachment box should order attachments from newest to oldest", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
const resData = { res_id: partnerId, res_model: "res.partner" };
pyEnv["ir.attachment"].create([
{ name: "A.txt", mimetype: "text/plain", ...resData },
{ name: "B.txt", mimetype: "text/plain", ...resData },
{ name: "C.txt", mimetype: "text/plain", ...resData },
]);
await start();
await openFormView("res.partner", partnerId);
await contains(".o-mail-Chatter [aria-label='Attach files']", { text: "3" });
await click(".o-mail-Chatter [aria-label='Attach files']"); // open attachment box
await contains(":nth-child(1 of .o-mail-AttachmentContainer)", { text: "C.txt" });
await contains(":nth-child(2 of .o-mail-AttachmentContainer)", { text: "B.txt" });
await contains(":nth-child(3 of .o-mail-AttachmentContainer)", { text: "A.txt" });
});
test("attachment box auto-closed on switch to record wih no attachments", async () => {
const pyEnv = await startServer();
const [partnerId_1, partnerId_2] = pyEnv["res.partner"].create([
{ display_name: "first partner" },
{ display_name: "second partner" },
]);
pyEnv["ir.attachment"].create([
{
mimetype: "text/plain",
name: "Blah.txt",
res_id: partnerId_1,
res_model: "res.partner",
},
]);
await start();
await openFormView("res.partner", partnerId_1, {
arch: `
<form>
<sheet></sheet>
<chatter open_attachments="True"/>
</form>`,
resIds: [partnerId_1, partnerId_2],
});
await contains(".o-mail-AttachmentBox");
await click(".o_pager_next");
await contains(".o-mail-AttachmentBox", { count: 0 });
});

View file

@ -0,0 +1,752 @@
import {
SIZES,
STORE_FETCH_ROUTES,
click,
contains,
defineMailModels,
dragenterFiles,
dropFiles,
insertText,
listenStoreFetch,
onRpcBefore,
openFormView,
patchUiSize,
scroll,
start,
startServer,
triggerHotkey,
waitStoreFetch,
} from "@mail/../tests/mail_test_helpers";
import { describe, expect, test } from "@odoo/hoot";
import { Deferred, advanceTime } from "@odoo/hoot-mock";
import {
asyncStep,
defineActions,
getService,
mockService,
serverState,
waitForSteps,
} from "@web/../tests/web_test_helpers";
import { DELAY_FOR_SPINNER } from "@mail/chatter/web_portal/chatter";
import { queryFirst } from "@odoo/hoot-dom";
describe.current.tags("desktop");
defineMailModels();
test("simple chatter on a record", async () => {
const pyEnv = await startServer();
onRpcBefore((route, args) => {
if (
(route.startsWith("/mail") || route.startsWith("/discuss")) &&
!STORE_FETCH_ROUTES.includes(route)
) {
asyncStep(`${route} - ${JSON.stringify(args)}`);
}
});
listenStoreFetch(undefined, { logParams: ["mail.thread"] });
await start();
await waitStoreFetch(["failures", "systray_get_activities", "init_messaging"]);
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
await openFormView("res.partner", partnerId);
await contains(".o-mail-Chatter-topbar");
await contains(".o-mail-Thread");
await waitStoreFetch(
[
[
"mail.thread",
{
access_params: {},
request_list: [
"activities",
"attachments",
"contact_fields",
"followers",
"scheduledMessages",
"suggestedRecipients",
],
thread_id: partnerId,
thread_model: "res.partner",
},
],
],
{
ignoreOrder: true,
stepsAfter: [
`/mail/thread/messages - {"thread_id":${partnerId},"thread_model":"res.partner","fetch_params":{"limit":30}}`,
],
}
);
});
test("can post a message on a record thread", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
onRpcBefore("/mail/message/post", (args) => {
asyncStep("/mail/message/post");
const expected = {
context: args.context,
post_data: {
body: "hey",
email_add_signature: true,
message_type: "comment",
subtype_xmlid: "mail.mt_comment",
},
thread_id: partnerId,
thread_model: "res.partner",
};
expect(args).toEqual(expected);
});
await start();
await openFormView("res.partner", partnerId);
await contains("button", { text: "Send message" });
await contains(".o-mail-Composer", { count: 0 });
await click("button", { text: "Send message" });
await contains(".o-mail-Composer");
await insertText(".o-mail-Composer-input", "hey");
await contains(".o-mail-Message", { count: 0 });
await click(".o-mail-Composer button[aria-label='Send']:enabled");
await contains(".o-mail-Message");
await waitForSteps(["/mail/message/post"]);
});
test("can post a note on a record thread", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
onRpcBefore("/mail/message/post", (args) => {
asyncStep("/mail/message/post");
const expected = {
context: args.context,
post_data: {
body: "hey",
email_add_signature: true,
message_type: "comment",
subtype_xmlid: "mail.mt_note",
},
thread_id: partnerId,
thread_model: "res.partner",
};
expect(args).toEqual(expected);
});
await start();
await openFormView("res.partner", partnerId);
await contains("button", { text: "Log note" });
await contains(".o-mail-Composer", { count: 0 });
await click("button", { text: "Log note" });
await contains(".o-mail-Composer");
await insertText(".o-mail-Composer-input", "hey");
await contains(".o-mail-Message", { count: 0 });
await click(".o-mail-Composer button:enabled", { text: "Log" });
await contains(".o-mail-Message");
await waitForSteps(["/mail/message/post"]);
});
test("No attachment loading spinner when creating records", async () => {
await start();
await openFormView("res.partner");
await contains("button[aria-label='Attach files']");
await contains("button[aria-label='Attach files'] .fa-spin", { count: 0 });
});
test("No attachment loading spinner when switching from loading record to creation of record", async () => {
const def = new Deferred();
const pyEnv = await startServer();
listenStoreFetch("mail.thread", {
async onRpc() {
asyncStep("before mail.thread");
await def;
},
});
await start();
const partnerId = pyEnv["res.partner"].create({ name: "John" });
await openFormView("res.partner", partnerId);
await contains("button[aria-label='Attach files']");
await advanceTime(DELAY_FOR_SPINNER);
await contains("button[aria-label='Attach files'] .fa-spin");
await click(".o_control_panel_main_buttons .o_form_button_create");
await contains("button[aria-label='Attach files'] .fa-spin", { count: 0 });
await waitForSteps(["before mail.thread"]);
def.resolve();
await waitStoreFetch("mail.thread");
});
test("Composer toggle state is kept when switching from aside to bottom", async () => {
await patchUiSize({ size: SIZES.XXL });
const pyEnv = await startServer();
await start();
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
await openFormView("res.partner", partnerId);
await click("button", { text: "Send message" });
await contains(".o-mail-Form-chatter.o-aside .o-mail-Composer-input");
await patchUiSize({ size: SIZES.LG });
await contains(".o-mail-Form-chatter:not(.o-aside) .o-mail-Composer-input");
});
test("Textarea content is kept when switching from aside to bottom", async () => {
await patchUiSize({ size: SIZES.XXL });
const pyEnv = await startServer();
await start();
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
await openFormView("res.partner", partnerId);
await click("button", { text: "Send message" });
await contains(".o-mail-Form-chatter.o-aside .o-mail-Composer-input");
await insertText(".o-mail-Composer-input", "Hello world !");
await patchUiSize({ size: SIZES.LG });
await contains(".o-mail-Form-chatter:not(.o-aside) .o-mail-Composer-input");
await contains(".o-mail-Composer-input", { value: "Hello world !" });
});
test("Composer type is kept when switching from aside to bottom", async () => {
await patchUiSize({ size: SIZES.XXL });
const pyEnv = await startServer();
await start();
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
await openFormView("res.partner", partnerId);
await click("button", { text: "Log note" });
await patchUiSize({ size: SIZES.LG });
await contains(".o-mail-Form-chatter:not(.o-aside) .o-mail-Composer-input");
await contains("button.btn-primary", { text: "Log note" });
await contains("button:not(.btn-primary)", { text: "Send message" });
});
test("chatter: drop attachments", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
const text = new File(["hello, world"], "text.txt", { type: "text/plain" });
const text2 = new File(["hello, worldub"], "text2.txt", { type: "text/plain" });
const text3 = new File(["hello, world"], "text3.txt", { type: "text/plain" });
await start();
await openFormView("res.partner", partnerId);
const files = [text, text2];
await dragenterFiles(".o-mail-Chatter", files);
await contains(".o-Dropzone");
await contains(".o-mail-AttachmentContainer", { count: 0 });
await dropFiles(".o-Dropzone", files);
await contains(".o-mail-AttachmentContainer:not(.o-isUploading)", { count: 2 });
const extraFiles = [text3];
await dragenterFiles(".o-mail-Chatter", extraFiles);
await dropFiles(".o-Dropzone", extraFiles);
await contains(".o-mail-AttachmentContainer:not(.o-isUploading)", { count: 3 });
});
test("chatter: drop attachment should refresh thread data with hasParentReloadOnAttachmentsChange prop", async () => {
await patchUiSize({ size: SIZES.XXL });
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
const textPdf = new File([new Uint8Array(1)], "text.pdf", { type: "application/pdf" });
await start();
await openFormView("res.partner", partnerId, {
arch: `
<form>
<sheet>
<field name="name"/>
</sheet>
<div class="o_attachment_preview" />
<chatter reload_on_post="True" reload_on_attachment="True"/>
</form>`,
});
await dragenterFiles(".o-mail-Chatter", [textPdf]);
await dropFiles(".o-Dropzone", [textPdf]);
await contains(".o-mail-Attachment iframe", { count: 1 });
});
test("should display subject when subject isn't infered from the record", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Demo" });
pyEnv["mail.message"].create({
body: "not empty",
model: "res.partner",
res_id: partnerId,
subject: "Salutations, voyageur",
});
await start();
await openFormView("res.partner", partnerId);
await contains(".o-mail-Message", { text: "Subject: Salutations, voyageurnot empty" });
});
test("should not display user notification messages in chatter", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
pyEnv["mail.message"].create({
message_type: "user_notification",
model: "res.partner",
res_id: partnerId,
});
await start();
await openFormView("res.partner", partnerId);
await contains(".o-mail-Thread", { text: "The conversation is empty." });
await contains(".o-mail-Message", { count: 0 });
});
test('post message with "CTRL-Enter" keyboard shortcut in chatter', async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
await start();
await openFormView("res.partner", partnerId);
await click("button", { text: "Send message" });
await contains(".o-mail-Message", { count: 0 });
await insertText(".o-mail-Composer-input", "Test");
triggerHotkey("control+Enter");
await contains(".o-mail-Message");
});
test("base rendering when chatter has no attachment", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
for (let i = 0; i < 60; i++) {
pyEnv["mail.message"].create({
body: "not empty",
model: "res.partner",
res_id: partnerId,
});
}
await start();
await openFormView("res.partner", partnerId);
await contains(".o-mail-Chatter");
await contains(".o-mail-Chatter-topbar");
await contains(".o-mail-AttachmentBox", { count: 0 });
await contains(".o-mail-Thread");
await contains(".o-mail-Message", { count: 30 });
});
test("base rendering when chatter has no record", async () => {
await start();
await openFormView("res.partner");
await contains(".o-mail-Chatter");
await contains(".o-mail-Chatter-topbar");
await contains(".o-mail-AttachmentBox", { count: 0 });
await contains(".o-mail-Chatter .o-mail-Thread");
await contains(".o-mail-Message");
await contains(".o-mail-Message-author", { text: "Mitchell Admin" });
await contains(".o-mail-Message-body", { text: "Creating a new record..." });
await contains("button", { count: 0, text: "Load More" });
});
test("base rendering when chatter has attachments", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
pyEnv["ir.attachment"].create([
{
mimetype: "text/plain",
name: "Blah.txt",
res_id: partnerId,
res_model: "res.partner",
},
{
mimetype: "text/plain",
name: "Blu.txt",
res_id: partnerId,
res_model: "res.partner",
},
]);
await start();
await openFormView("res.partner", partnerId);
await contains(".o-mail-Chatter");
await contains(".o-mail-Chatter-topbar");
await contains(".o-mail-AttachmentBox", { count: 0 });
});
test("show attachment box", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
pyEnv["ir.attachment"].create([
{
mimetype: "text/plain",
name: "Blah.txt",
res_id: partnerId,
res_model: "res.partner",
},
{
mimetype: "text/plain",
name: "Blu.txt",
res_id: partnerId,
res_model: "res.partner",
},
]);
await start();
await openFormView("res.partner", partnerId);
await contains(".o-mail-Chatter");
await contains(".o-mail-Chatter-topbar");
await contains("button[aria-label='Attach files']");
await contains("button[aria-label='Attach files']", { text: "2" });
await contains(".o-mail-AttachmentBox", { count: 0 });
await click("button[aria-label='Attach files']");
await contains(".o-mail-AttachmentBox");
});
test("composer show/hide on log note/send message", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
await start();
await openFormView("res.partner", partnerId);
await contains("button", { text: "Send message" });
await contains("button", { text: "Log note" });
await contains(".o-mail-Composer", { count: 0 });
await click("button", { text: "Send message" });
await contains(".o-mail-Composer");
expect(".o-mail-Composer-input").toBeFocused();
await click("button", { text: "Log note" });
await contains(".o-mail-Composer");
expect(".o-mail-Composer-input").toBeFocused();
await click("button", { text: "Log note" });
await contains(".o-mail-Composer", { count: 0 });
await click("button", { text: "Send message" });
await contains(".o-mail-Composer");
await click("button", { text: "Send message" });
await contains(".o-mail-Composer", { count: 0 });
});
test('do not post message with "Enter" keyboard shortcut', async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
await start();
await openFormView("res.partner", partnerId);
await click("button", { text: "Send message" });
await contains(".o-mail-Message", { count: 0 });
await insertText(".o-mail-Composer-input", "Test");
triggerHotkey("Enter");
// weak test, no guarantee that we waited long enough for the potential message to be posted
await contains(".o-mail-Message", { count: 0 });
});
test("should not display subject when subject is the same as the thread name", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({
name: "Salutations, voyageur",
});
pyEnv["mail.message"].create({
body: "not empty",
model: "res.partner",
res_id: partnerId,
subject: "Salutations, voyageur",
});
await start();
await openFormView("res.partner", partnerId);
await contains(".o-mail-Message", { text: "not empty" });
await contains(".o-mail-Message", {
count: 0,
text: "Subject: Salutations, voyageurnot empty",
});
});
test("scroll position is kept when navigating from one record to another", async () => {
await patchUiSize({ size: SIZES.XXL });
const pyEnv = await startServer();
const partnerId_1 = pyEnv["res.partner"].create({ name: "Harry Potter" });
const partnerId_2 = pyEnv["res.partner"].create({ name: "Ron Weasley" });
// Fill both channels with random messages in order for the scrollbar to
// appear.
pyEnv["mail.message"].create(
Array(50)
.fill(0)
.map((_, index) => ({
body: "Non Empty Body ".repeat(25),
model: "res.partner",
res_id: index < 20 ? partnerId_1 : partnerId_2,
}))
);
await start();
await openFormView("res.partner", partnerId_1);
await contains(".o-mail-Message", { count: 20 });
const clientHeight1 = queryFirst(".o-mail-Chatter:first").clientHeight; // client height might change (cause: breadcrumb)
const scrollValue1 = queryFirst(".o-mail-Chatter:first").scrollHeight / 2;
await contains(".o-mail-Chatter", { scroll: 0 });
await scroll(".o-mail-Chatter", scrollValue1);
await openFormView("res.partner", partnerId_2);
await contains(".o-mail-Message", { count: 30 });
const clientHeight2 = queryFirst(".o-mail-Chatter:first").clientHeight;
const scrollValue2 = queryFirst(".o-mail-Chatter:first").scrollHeight / 3;
await scroll(".o-mail-Chatter", scrollValue2);
await openFormView("res.partner", partnerId_1);
await contains(".o-mail-Message", { count: 20 });
const clientHeight3 = queryFirst(".o-mail-Chatter:first").clientHeight;
await contains(".o-mail-Chatter", { scroll: scrollValue1 - (clientHeight3 - clientHeight1) });
await openFormView("res.partner", partnerId_2);
await contains(".o-mail-Message", { count: 30 });
const clientHeight4 = queryFirst(".o-mail-Chatter:first").clientHeight;
await contains(".o-mail-Chatter", { scroll: scrollValue2 - (clientHeight4 - clientHeight2) });
});
test("basic chatter rendering", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ display_name: "second partner" });
await start();
await openFormView("res.partner", partnerId, {
arch: `
<form string="Partners">
<sheet>
<field name="name"/>
</sheet>
<chatter/>
</form>`,
});
await contains(".o-mail-Chatter");
});
test('chatter just contains "creating a new record" message during the creation of a new record after having displayed a chatter for an existing record', async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
const views = {
"res.partner,false,form": `
<form string="Partners">
<sheet>
<field name="name"/>
</sheet>
<chatter/>
</form>`,
};
await start({ serverData: { views } });
await openFormView("res.partner", partnerId);
await click(".o_control_panel_main_buttons .o_form_button_create");
await contains(".o-mail-Message");
await contains(".o-mail-Message-body", { text: "Creating a new record..." });
});
test("should display subject when subject is not the same as the default subject", async () => {
const pyEnv = await startServer();
const fakeId = pyEnv["res.fake"].create({ name: "Salutations, voyageur" });
pyEnv["mail.message"].create({
body: "not empty",
model: "res.fake",
res_id: fakeId,
subject: "Another Subject",
});
await start();
await openFormView("res.fake", fakeId);
await contains(".o-mail-Message", { text: "Subject: Another Subjectnot empty" });
});
test("should not display subject when subject is the same as the default subject", async () => {
const pyEnv = await startServer();
const fakeId = pyEnv["res.fake"].create({ name: "Salutations, voyageur" });
pyEnv["mail.message"].create({
body: "not empty",
model: "res.fake",
res_id: fakeId,
subject: "Custom Default Subject",
});
await start();
await openFormView("res.fake", fakeId);
await contains(".o-mail-Message", { text: "not empty" });
await contains(".o-mail-Message", {
count: 0,
text: "Subject: Custom Default Subjectnot empty",
});
});
test("should not display subject when subject is the same as the thread name with custom default subject", async () => {
const pyEnv = await startServer();
const fakeId = pyEnv["res.fake"].create({ name: "Salutations, voyageur" });
pyEnv["mail.message"].create({
body: "not empty",
model: "res.fake",
res_id: fakeId,
subject: "Salutations, voyageur",
});
await start();
await openFormView("res.fake", fakeId);
await contains(".o-mail-Message", { text: "not empty" });
await contains(".o-mail-Message", {
count: 0,
text: "Subject: Custom Default Subjectnot empty",
});
});
test("chatter updating", async () => {
const pyEnv = await startServer();
const [partnerId_1, partnerId_2] = pyEnv["res.partner"].create([
{ display_name: "first partner" },
{ display_name: "second partner" },
]);
pyEnv["mail.message"].create({
body: "not empty",
model: "res.partner",
res_id: partnerId_2,
});
await start();
await openFormView("res.partner", partnerId_1, {
arch: `
<form string="Partners">
<sheet>
<field name="name"/>
</sheet>
<chatter/>
</form>`,
resIds: [partnerId_1, partnerId_2],
});
await click(".o_pager_next");
await contains(".o-mail-Message");
});
test("chatter message actions appear only after saving the form", async () => {
await start();
await openFormView("res.partner");
await contains(".o-mail-Message");
await contains(".o-mail-Message-actions", { count: 0 });
await click(".o_form_button_save");
await click("button", { text: "Send message" });
await insertText(".o-mail-Composer-input", "hey");
await click(".o-mail-Composer-send:enabled");
await contains(".o-mail-Message-actions");
});
test("post message on draft record", async () => {
await start();
await openFormView("res.partner", undefined, {
arch: `
<form string="Partners">
<sheet>
<field name="name"/>
</sheet>
<chatter/>
</form>`,
});
await click("button", { text: "Send message" });
await insertText(".o-mail-Composer-input", "Test");
await click(".o-mail-Composer button[aria-label='Send']:enabled");
await contains(".o-mail-Message");
await contains(".o-mail-Message-content", { text: "Test" });
});
test("schedule activities on draft record should prompt with scheduling an activity (proceed with action)", async () => {
const wizardOpened = new Deferred();
mockService("action", {
doAction(action, options) {
if (action.res_model === "res.partner") {
return super.doAction(...arguments);
} else if (action.res_model === "mail.activity.schedule") {
asyncStep("mail.activity.schedule");
expect(action.context.active_model).toBe("res.partner");
expect(Number(action.context.active_id)).toBeGreaterThan(0);
options.onClose();
wizardOpened.resolve();
} else {
asyncStep("Unexpected action" + action.res_model);
}
},
});
await start();
await openFormView("res.partner", undefined, {
arch: `
<form string="Partners">
<sheet>
<field name="name"/>
</sheet>
<chatter/>
</form>`,
});
await click("button", { text: "Activity" });
await wizardOpened;
await waitForSteps(["mail.activity.schedule"]);
});
test("upload attachment on draft record", async () => {
const text = new File(["hello, world"], "text.text", { type: "text/plain" });
await start();
await openFormView("res.partner", undefined, {
arch: `
<form string="Partners">
<sheet>
<field name="name"/>
</sheet>
<chatter/>
</form>`,
});
await contains("button[aria-label='Attach files']");
await contains("button[aria-label='Attach files']", { count: 0, text: "1" });
await dragenterFiles(".o-mail-Chatter", [text]);
await dropFiles(".o-Dropzone", [text]);
await contains("button[aria-label='Attach files']", { text: "1" });
});
test("Follower count of draft record is set to 0", async () => {
await start();
await openFormView("res.partner");
await contains(".o-mail-Followers", { text: "0" });
});
test("Mentions in composer should still work when using pager", async () => {
const pyEnv = await startServer();
const [partnerId_1, partnerId_2] = pyEnv["res.partner"].create([
{ display_name: "Partner 1" },
{ display_name: "Partner 2" },
]);
await patchUiSize({ size: SIZES.LG });
await start();
await openFormView("res.partner", partnerId_1, { resIds: [partnerId_1, partnerId_2] });
await click("button", { text: "Log note" });
await click(".o_pager_next");
await insertText(".o-mail-Composer-input", "@");
// all records in DB: Mitchell Admin | Hermit | Public user except OdooBot
await contains(".o-mail-Composer-suggestion", { count: 3 });
});
test("form views in dialogs do not have chatter", async () => {
defineActions([
{
id: 1,
name: "Partner",
res_model: "res.partner",
views: [[false, "form"]],
target: "new",
},
]);
await start();
await getService("action").doAction(1);
await contains(".o_dialog .o_form_view");
await contains(".o-mail-Form-Chatter", { count: 0 });
});
test("should display the subject even if the record name is false", async () => {
const pyEnv = await startServer();
const fakeId = pyEnv["res.fake"].create({ name: false });
pyEnv["mail.message"].create({
body: "not empty",
model: "res.fake",
res_id: fakeId,
subject: "Salutations, voyageur",
});
await start();
await openFormView("res.fake", fakeId);
await contains(".o-mail-Message", { text: "Subject: Salutations, voyageurnot empty" });
});
test("Update message recipients without saving", async () => {
const pyEnv = await startServer();
pyEnv["res.partner"].write([serverState.partnerId], { email: "mitchell@odoo.com" });
const partnerId = pyEnv["res.partner"].create({
name: "John Doe",
email: "john@doe.be",
});
const fakeId = pyEnv["res.fake"].create({
name: "John Doe",
partner_id: partnerId,
});
await start();
await openFormView("res.fake", fakeId);
await click("button", { text: "Send message" });
await contains(".o-mail-RecipientsInput .o_tag_badge_text", { text: "John Doe" });
await click(".o_field_many2one_selection input");
await click(".o-autocomplete--dropdown-item", { text: "Mitchell Admin" });
await contains(".o-mail-RecipientsInput .o_tag_badge_text", { text: "Mitchell Admin" });
});
test("Update primary email in recipient without saving", async () => {
const pyEnv = await startServer();
pyEnv["res.partner"].write([serverState.partnerId], { email: "mitchell@odoo.com" });
const partnerId = pyEnv["res.partner"].create({
name: "John Doe",
email: "john@doe.be",
});
const fakeId = pyEnv["res.fake"].create({
name: "Fake record",
partner_id: partnerId,
});
await start();
await openFormView("res.fake", fakeId);
await click("button", { text: "Send message" });
await insertText("div[name='email_cc'] input", "test@test.be");
document.querySelector("div[name='email_cc'] input").blur();
await contains(".o-mail-RecipientsInput .o_tag_badge_text", { text: "test@test.be" });
});

View file

@ -0,0 +1,119 @@
import {
SIZES,
click,
contains,
defineMailModels,
insertText,
openFormView,
patchUiSize,
scroll,
start,
startServer,
triggerHotkey,
} from "@mail/../tests/mail_test_helpers";
import { describe, test } from "@odoo/hoot";
import { serverState } from "@web/../tests/web_test_helpers";
import { HIGHLIGHT_CLASS } from "@mail/core/common/message_search_hook";
describe.current.tags("desktop");
defineMailModels();
test("Chatter should display search icon", async () => {
const pyEnv = await startServer();
patchUiSize({ size: SIZES.XXL });
await start();
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
await openFormView("res.partner", partnerId);
await contains("[title='Search Messages']");
});
test("Click on the search icon should open the search form", async () => {
const pyEnv = await startServer();
patchUiSize({ size: SIZES.XXL });
await start();
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
await openFormView("res.partner", partnerId);
await click("[title='Search Messages']");
await contains(".o_searchview");
await contains(".o_searchview_input");
});
test("Search in chatter", async () => {
patchUiSize({ size: SIZES.XXL });
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
pyEnv["mail.message"].create({
body: "not empty",
model: "res.partner",
res_id: partnerId,
});
await start();
await openFormView("res.partner", partnerId);
await click("[title='Search Messages']");
await insertText(".o_searchview_input", "empty");
triggerHotkey("Enter");
await contains(".o-mail-SearchMessageResult .o-mail-Message");
await click(".o-mail-MessageCard-jump");
await contains(".o-mail-Message.o-highlighted .o-mail-Message-content", { text: "not empty" });
});
test("Close button should close the search panel", async () => {
patchUiSize({ size: SIZES.XXL });
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
pyEnv["mail.message"].create({
body: "not empty",
model: "res.partner",
res_id: partnerId,
});
await start();
await openFormView("res.partner", partnerId);
await click(".o-mail-Chatter-topbar [title='Search Messages']");
await insertText(".o_searchview_input", "empty");
triggerHotkey("Enter");
await contains(".o-mail-SearchMessageResult .o-mail-Message");
await click(".o-mail-SearchMessageInput [title='Close']");
await contains(".o-mail-SearchMessageInput", { count: 0 });
});
test("Search in chatter should be hightligted", async () => {
patchUiSize({ size: SIZES.XXL });
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
pyEnv["mail.message"].create({
body: "not empty",
model: "res.partner",
res_id: partnerId,
});
await start();
await openFormView("res.partner", partnerId);
await click("[title='Search Messages']");
await insertText(".o_searchview_input", "empty");
triggerHotkey("Enter");
await contains(`.o-mail-SearchMessageResult .o-mail-Message .${HIGHLIGHT_CLASS}`);
});
test("Scrolling bottom in non-aside chatter should load more searched message", async () => {
patchUiSize({ size: SIZES.LG }); // non-aside
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
for (let i = 0; i < 60; i++) {
pyEnv["mail.message"].create({
author_id: serverState.partnerId,
body: "This is a message",
attachment_ids: [],
message_type: "comment",
model: "res.partner",
res_id: partnerId,
});
}
await start();
await openFormView("res.partner", partnerId);
await click("[title='Search Messages']");
await insertText(".o_searchview_input", "message");
triggerHotkey("Enter");
await contains(".o-mail-SearchMessageResult .o-mail-Message", { count: 30 });
await scroll(".o_content", "bottom");
await contains(".o-mail-SearchMessageResult .o-mail-Message", { count: 60 });
});

View file

@ -0,0 +1,229 @@
import {
click,
contains,
defineMailModels,
listenStoreFetch,
openFormView,
start,
startServer,
waitStoreFetch,
} from "@mail/../tests/mail_test_helpers";
import { describe, test } from "@odoo/hoot";
import { Deferred, advanceTime } from "@odoo/hoot-mock";
import { asyncStep, waitForSteps } from "@web/../tests/web_test_helpers";
import { DELAY_FOR_SPINNER } from "@mail/chatter/web_portal/chatter";
describe.current.tags("desktop");
defineMailModels();
test("base rendering", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
await start();
await openFormView("res.partner", partnerId);
await contains(".o-mail-Chatter-topbar");
await contains("button", { text: "Send message" });
await contains("button", { text: "Log note" });
await contains("button", { text: "Activity" });
await contains("button[aria-label='Attach files']");
await contains(".o-mail-Followers");
});
test("rendering with multiple partner followers", async () => {
const pyEnv = await startServer();
const [partnerId_1, partnerId_2, partnerId_3] = pyEnv["res.partner"].create([
{ name: "Eden Hazard" },
{ name: "Jean Michang" },
{},
]);
pyEnv["mail.followers"].create([
{
partner_id: partnerId_2,
res_id: partnerId_3,
res_model: "res.partner",
},
{
partner_id: partnerId_1,
res_id: partnerId_3,
res_model: "res.partner",
},
]);
await start();
await openFormView("res.partner", partnerId_3);
await contains(".o-mail-Followers");
await contains(".o-mail-Followers-button");
await click(".o-mail-Followers-button");
await contains(".o-mail-Followers-dropdown");
await contains(".o-mail-Follower", { count: 2 });
await contains(":nth-child(1 of .o-mail-Follower)", { text: "Jean Michang" });
await contains(":nth-child(2 of .o-mail-Follower)", { text: "Eden Hazard" });
});
test("log note toggling", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
await start();
await openFormView("res.partner", partnerId);
await contains("button:not(.active)", { text: "Log note" });
await contains(".o-mail-Composer", { count: 0 });
await click("button", { text: "Log note" });
await contains("button.active", { text: "Log note" });
await contains(".o-mail-Composer .o-mail-Composer-input[placeholder='Log an internal note…']");
await click("button", { text: "Log note" });
await contains("button:not(.active)", { text: "Log note" });
await contains(".o-mail-Composer", { count: 0 });
});
test("send message toggling", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
await start();
await openFormView("res.partner", partnerId);
await contains("button:not(.active)", { text: "Send message" });
await contains(".o-mail-Composer", { count: 0 });
await click("button", { text: "Send message" });
await contains("button.active", { text: "Send message" });
await contains(".o-mail-Composer-input[placeholder='Send a message to followers…']");
await click("button", { text: "Send message" });
await contains("button:not(.active)", { text: "Send message" });
await contains(".o-mail-Composer", { count: 0 });
});
test("log note/send message switching", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
await start();
await openFormView("res.partner", partnerId);
await contains("button:not(.active)", { text: "Send message" });
await contains("button:not(.active)", { text: "Log note" });
await contains(".o-mail-Composer", { count: 0 });
await click("button", { text: "Send message" });
await contains("button.active", { text: "Send message" });
await contains("button:not(.active)", { text: "Log note" });
await contains(".o-mail-Composer-input[placeholder='Send a message to followers…']");
await click("button", { text: "Log note" });
await contains("button:not(.active)", { text: "Send message" });
await contains("button.active", { text: "Log note" });
await contains(".o-mail-Composer-input[placeholder='Log an internal note…']");
});
test("attachment counter without attachments", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
await start();
await openFormView("res.partner", partnerId);
await contains("button[aria-label='Attach files']");
await contains("button[aria-label='Attach files']", { count: 0, text: "0" });
});
test("attachment counter with attachments", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
pyEnv["ir.attachment"].create([
{
mimetype: "text/plain",
name: "Blah.txt",
res_id: partnerId,
res_model: "res.partner",
},
{
mimetype: "text/plain",
name: "Blu.txt",
res_id: partnerId,
res_model: "res.partner",
},
]);
await start();
await openFormView("res.partner", partnerId);
await contains("button[aria-label='Attach files']", { text: "2" });
});
test("attachment counter while loading attachments", async () => {
const def = new Deferred();
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
listenStoreFetch("mail.thread", {
async onRpc() {
asyncStep("before mail.thread");
await def;
},
});
await start();
await openFormView("res.partner", partnerId);
await contains("button[aria-label='Attach files']");
await advanceTime(DELAY_FOR_SPINNER);
await contains("button[aria-label='Attach files'] .fa-spin");
await contains("button[aria-label='Attach files']", { count: 0, text: "0" });
await waitForSteps(["before mail.thread"]);
def.resolve();
await waitStoreFetch("mail.thread");
});
test("attachment counter transition when attachments become loaded", async () => {
const def = new Deferred();
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
listenStoreFetch("mail.thread", {
async onRpc() {
asyncStep("before mail.thread");
await def;
},
});
await start();
await openFormView("res.partner", partnerId);
await contains("button[aria-label='Attach files']");
await advanceTime(DELAY_FOR_SPINNER);
await contains("button[aria-label='Attach files'] .fa-spin");
await waitForSteps(["before mail.thread"]);
def.resolve();
await waitStoreFetch("mail.thread");
await contains("button[aria-label='Attach files'] .fa-spin", { count: 0 });
});
test("attachment icon open directly the file uploader if there is no attachment yet", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
await start();
await openFormView("res.partner", partnerId);
await contains(".o-mail-Chatter-fileUploader");
await contains(".o-mail-AttachmentBox", { count: 0 });
});
test("attachment icon open the attachment box when there is at least 1 attachment", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
pyEnv["ir.attachment"].create([
{
mimetype: "text/plain",
name: "Blah.txt",
res_id: partnerId,
res_model: "res.partner",
},
]);
await start();
await openFormView("res.partner", partnerId);
await contains("button[aria-label='Attach files']");
await contains(".o-mail-AttachmentBox", { count: 0 });
await contains(".o-mail-Chatter-fileUploader", { count: 0 });
await click("button[aria-label='Attach files']");
await contains(".o-mail-AttachmentBox");
await contains(".o-mail-Chatter-fileUploader");
});
test("composer state conserved when clicking on another topbar button", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
await start();
await openFormView("res.partner", partnerId);
await contains(".o-mail-Chatter-topbar");
await contains("button", { text: "Send message" });
await contains("button", { text: "Log note" });
await contains("button[aria-label='Attach files']");
await click("button", { text: "Log note" });
await contains("button.active", { text: "Log note" });
await contains("button:not(.active)", { text: "Send message" });
await click(".o-mail-Chatter-topbar button[aria-label='Attach files']");
await contains("button.active", { text: "Log note" });
await contains("button:not(.active)", { text: "Send message" });
});

View file

@ -0,0 +1,33 @@
import {
click,
contains,
defineMailModels,
openFormView,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { describe, test } from "@odoo/hoot";
describe.current.tags("desktop");
defineMailModels();
test("base rendering follow, edit subscription and unfollow button", async () => {
const pyEnv = await startServer();
const threadId = pyEnv["res.partner"].create({});
await start();
await openFormView("res.partner", threadId);
await contains(".o-mail-Followers-counter", { text: "0" });
await contains("[title='Show Followers'] .fa-user-o");
await click("[title='Show Followers']");
await click(".o-dropdown-item", { text: "Follow" });
await contains(".o-mail-Followers-counter", { text: "1" });
await contains("[title='Show Followers'] .fa-user");
await click("[title='Show Followers']");
await contains(".o-mail-Followers-dropdown");
await click("[title='Edit subscription']");
await contains(".o-mail-Followers-dropdown", { count: 0 });
await click("[title='Show Followers']");
await click(".o-dropdown-item", { text: "Unfollow" });
await contains(".o-mail-Followers-counter", { text: "0" });
await contains("[title='Show Followers'] .fa-user-o");
});

View file

@ -0,0 +1,183 @@
import {
click,
contains,
defineMailModels,
editInput,
onRpcBefore,
openFormView,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { describe, expect, test } from "@odoo/hoot";
import { Deferred } from "@odoo/hoot-mock";
import { asyncStep, mockService, onRpc, waitForSteps } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
defineMailModels();
test("base rendering not editable", async () => {
const pyEnv = await startServer();
const [threadId, partnerId] = pyEnv["res.partner"].create([
{ hasWriteAccess: false },
{ hasWriteAccess: false },
]);
pyEnv["mail.followers"].create({
is_active: true,
partner_id: partnerId,
res_id: threadId,
res_model: "res.partner",
});
await start();
await openFormView("res.partner", threadId);
await click(".o-mail-Followers-button");
await contains(".o-mail-Follower");
await contains(".o-mail-Follower-details");
await contains(".o-mail-Follower-avatar");
await contains(".o-mail-Follower-action", { count: 0 });
});
test("base rendering editable", async () => {
const pyEnv = await startServer();
const [threadId, partnerId] = pyEnv["res.partner"].create([{}, {}]);
pyEnv["mail.followers"].create({
is_active: true,
partner_id: partnerId,
res_id: threadId,
res_model: "res.partner",
});
await start();
await openFormView("res.partner", threadId);
await click(".o-mail-Followers-button");
await contains(".o-mail-Follower");
await contains(".o-mail-Follower-details");
await contains(".o-mail-Follower-avatar");
await contains(".o-mail-Follower");
await contains("[title='Edit subscription']");
await contains("[title='Remove this follower']");
});
test("click on partner follower details", async () => {
const pyEnv = await startServer();
const [threadId, partnerId] = pyEnv["res.partner"].create([{}, {}]);
pyEnv["mail.followers"].create({
is_active: true,
partner_id: partnerId,
res_id: threadId,
res_model: "res.partner",
});
const openFormDef = new Deferred();
mockService("action", {
doAction(action) {
if (action?.res_id !== partnerId) {
return super.doAction(...arguments);
}
asyncStep("do_action");
expect(action.res_id).toBe(partnerId);
expect(action.res_model).toBe("res.partner");
expect(action.type).toBe("ir.actions.act_window");
openFormDef.resolve();
},
});
await start();
await openFormView("res.partner", threadId);
await click(".o-mail-Followers-button");
await contains(".o-mail-Follower");
await contains(".o-mail-Follower-details");
await click(".o-mail-Follower-details:first");
await openFormDef;
await waitForSteps(["do_action"]); // redirect to partner profile
});
test("click on edit follower", async () => {
const pyEnv = await startServer();
const [threadId, partnerId] = pyEnv["res.partner"].create([{}, {}]);
pyEnv["mail.followers"].create({
is_active: true,
partner_id: partnerId,
res_id: threadId,
res_model: "res.partner",
});
onRpcBefore("/mail/read_subscription_data", () => asyncStep("fetch_subtypes"));
await start();
await openFormView("res.partner", threadId);
await click(".o-mail-Followers-button");
await contains(".o-mail-Follower");
await contains("[title='Edit subscription']");
await click("[title='Edit subscription']");
await contains(".o-mail-Follower", { count: 0 });
await waitForSteps(["fetch_subtypes"]);
await contains(".o-mail-FollowerSubtypeDialog");
});
test("edit follower and close subtype dialog", async () => {
const pyEnv = await startServer();
const [threadId, partnerId] = pyEnv["res.partner"].create([{}, {}]);
pyEnv["mail.followers"].create({
is_active: true,
partner_id: partnerId,
res_id: threadId,
res_model: "res.partner",
});
onRpcBefore("/mail/read_subscription_data", () => asyncStep("fetch_subtypes"));
await start();
await openFormView("res.partner", threadId);
await click(".o-mail-Followers-button");
await contains(".o-mail-Follower");
await contains("[title='Edit subscription']");
await click("[title='Edit subscription']");
await contains(".o-mail-FollowerSubtypeDialog");
await waitForSteps(["fetch_subtypes"]);
await click(".o-mail-FollowerSubtypeDialog button", { text: "Cancel" });
await contains(".o-mail-FollowerSubtypeDialog", { count: 0 });
});
test("remove a follower in a dirty form view", async () => {
const pyEnv = await startServer();
const [threadId, partnerId] = pyEnv["res.partner"].create([{}, {}]);
pyEnv["discuss.channel"].create({ name: "General", display_name: "General" });
pyEnv["mail.followers"].create({
is_active: true,
partner_id: partnerId,
res_id: threadId,
res_model: "res.partner",
});
await start();
await openFormView("res.partner", threadId, {
arch: `
<form>
<field name="name"/>
<field name="channel_ids" widget="many2many_tags"/>
<chatter/>
</form>`,
});
await click(".o_field_many2many_tags[name='channel_ids'] input");
await click(".dropdown-item", { text: "General" });
await contains(".o_tag", { text: "General" });
await contains(".o-mail-Followers-counter", { text: "1" });
await editInput(document.body, ".o_field_char[name=name] input", "some value");
await click(".o-mail-Followers-button");
await click("[title='Remove this follower']");
await contains(".o-mail-Followers-counter", { text: "0" });
await contains(".o_field_char[name=name] input", { value: "some value" });
await contains(".o_tag", { text: "General" });
});
test("removing a follower should reload form view", async function () {
const pyEnv = await startServer();
const [threadId, partnerId] = pyEnv["res.partner"].create([{}, {}]);
pyEnv["mail.followers"].create({
is_active: true,
partner_id: partnerId,
res_id: threadId,
res_model: "res.partner",
});
onRpc("res.partner", "web_read", ({ args }) => asyncStep(`read ${args[0][0]}`));
await start();
await openFormView("res.partner", threadId);
await contains(".o-mail-Followers-button");
await waitForSteps([`read ${threadId}`]);
await click(".o-mail-Followers-button");
await click("[title='Remove this follower']");
await contains(".o-mail-Followers-counter", { text: "0" });
await waitForSteps([`read ${threadId}`]);
});

View file

@ -0,0 +1,199 @@
import {
click,
contains,
defineMailModels,
openFormView,
scroll,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { describe, expect, test } from "@odoo/hoot";
import { tick } from "@odoo/hoot-dom";
import { asyncStep, mockService, serverState, waitForSteps } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
defineMailModels();
test("base rendering not editable", async () => {
await start();
await openFormView("res.partner", undefined, {});
await contains(".o-mail-Followers");
await contains(".o-mail-Followers-button:disabled");
await contains(".o-mail-Followers-dropdown", { count: 0 });
await click(".o-mail-Followers-button");
await contains(".o-mail-Followers-dropdown", { count: 0 });
});
test("base rendering editable", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
await start();
await openFormView("res.partner", partnerId);
await contains(".o-mail-Followers");
await contains(".o-mail-Followers-button");
await contains(".o-mail-Followers-button:first:enabled");
await contains(".o-mail-Followers-dropdown", { count: 0 });
await click(".o-mail-Followers-button");
await contains(".o-mail-Followers-dropdown");
});
test('click on "add followers" button', async () => {
const pyEnv = await startServer();
const [partnerId_1, partnerId_2, partnerId_3] = pyEnv["res.partner"].create([
{ name: "Partner1" },
{ name: "François Perusse" },
{ name: "Partner3" },
]);
pyEnv["mail.followers"].create({
partner_id: partnerId_2,
email: "bla@bla.bla",
is_active: true,
res_id: partnerId_1,
res_model: "res.partner",
});
mockService("action", {
doAction(action, options) {
if (action?.res_model !== "mail.followers.edit") {
return super.doAction(...arguments);
}
asyncStep("action:open_view");
expect(action.context.default_res_model).toBe("res.partner");
expect(action.context.default_res_ids).toEqual([partnerId_1]);
expect(action.res_model).toBe("mail.followers.edit");
expect(action.type).toBe("ir.actions.act_window");
pyEnv["mail.followers"].create({
partner_id: partnerId_3,
email: "bla@bla.bla",
is_active: true,
name: "Wololo",
res_id: partnerId_1,
res_model: "res.partner",
});
options.onClose();
},
});
await start();
await openFormView("res.partner", partnerId_1);
await contains(".o-mail-Followers");
await contains(".o-mail-Followers-counter", { text: "1" });
await click(".o-mail-Followers-button");
await contains(".o-mail-Followers-dropdown");
await click("a", { text: "Add Followers" });
await contains(".o-mail-Followers-dropdown", { count: 0 });
await waitForSteps(["action:open_view"]);
await contains(".o-mail-Followers-counter", { text: "2" });
await click(".o-mail-Followers-button");
await contains(".o-mail-Follower", { count: 2 });
await contains(":nth-child(1 of .o-mail-Follower)", { text: "François Perusse" });
await contains(":nth-child(2 of .o-mail-Follower)", { text: "Partner3" });
});
test("click on remove follower", async () => {
const pyEnv = await startServer();
const [partnerId_1, partnerId_2] = pyEnv["res.partner"].create([
{ name: "Partner1" },
{ name: "Partner2" },
]);
pyEnv["mail.followers"].create({
partner_id: partnerId_2,
email: "bla@bla.bla",
is_active: true,
name: "Wololo",
res_id: partnerId_1,
res_model: "res.partner",
});
await start();
await openFormView("res.partner", partnerId_1);
await click(".o-mail-Followers-button");
await contains(".o-mail-Follower");
await click("[title='Remove this follower']");
await contains(".o-mail-Follower", { count: 0 });
await contains(".o-mail-Followers-dropdown");
});
test("Load 100 followers at once", async () => {
const pyEnv = await startServer();
const partnerIds = pyEnv["res.partner"].create(
[...Array(210).keys()].map((i) => ({ display_name: `Partner${i}`, name: `Partner${i}` }))
);
pyEnv["mail.followers"].create(
[...Array(210).keys()].map((i) => ({
is_active: true,
partner_id: i === 0 ? serverState.partnerId : partnerIds[i],
res_id: partnerIds[0],
res_model: "res.partner",
}))
);
await start();
await openFormView("res.partner", partnerIds[0]);
await contains("button[title='Show Followers']", { text: "210" });
await click("[title='Show Followers']");
await contains(".o-mail-Follower", { count: 100 });
await contains(".o-mail-Followers-dropdown", { text: "Load more" });
await scroll(".o-mail-Followers-dropdown", "bottom");
await contains(".o-mail-Follower", { count: 200 });
await tick(); // give enough time for the useVisible hook to register load more as hidden
await scroll(".o-mail-Followers-dropdown", "bottom");
await contains(".o-mail-Follower", { count: 209 });
await contains(".o-mail-Followers-dropdown span", { count: 0, text: "Load more" });
});
test("Load 100 recipients at once", async () => {
const pyEnv = await startServer();
const partnerIds = pyEnv["res.partner"].create(
[...Array(210).keys()].map((i) => ({
display_name: `Partner${i}`,
name: `Partner${i}`,
email: `partner${i}@example.com`,
}))
);
pyEnv["mail.followers"].create(
[...Array(210).keys()].map((i) => ({
is_active: true,
partner_id: i === 0 ? serverState.partnerId : partnerIds[i],
res_id: partnerIds[0],
res_model: "res.partner",
}))
);
await start();
await openFormView("res.partner", partnerIds[0]);
await contains("button[title='Show Followers']", { text: "210" });
});
test('Show "Add follower" and subtypes edition/removal buttons on all followers if user has write access', async () => {
const pyEnv = await startServer();
const [partnerId_1, partnerId_2] = pyEnv["res.partner"].create([
{ name: "Partner1" },
{ name: "Partner2" },
]);
pyEnv["mail.followers"].create([
{
is_active: true,
partner_id: serverState.partnerId,
res_id: partnerId_1,
res_model: "res.partner",
},
{
is_active: true,
partner_id: partnerId_2,
res_id: partnerId_1,
res_model: "res.partner",
},
]);
await start();
await openFormView("res.partner", partnerId_1);
await click(".o-mail-Followers-button");
await contains("a", { text: "Add Followers" });
await contains(":nth-child(1 of .o-mail-Follower)", {
contains: [["[title='Edit subscription']"], ["[title='Remove this follower']"]],
});
});
test('Show "No Followers" dropdown-item if there are no followers and user does not have write access', async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ hasWriteAccess: false });
await start();
await openFormView("res.partner", partnerId);
await click(".o-mail-Followers-button");
await contains("div.disabled", { text: "No Followers" });
});

View file

@ -0,0 +1,138 @@
import {
click,
contains,
defineMailModels,
openFormView,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { describe, test } from "@odoo/hoot";
import { serverState } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
defineMailModels();
test("simplest layout of a followed subtype", async () => {
const pyEnv = await startServer();
const subtypeId = pyEnv["mail.message.subtype"].create({
default: true,
name: "TestSubtype",
});
pyEnv["mail.followers"].create({
display_name: "François Perusse",
partner_id: serverState.partnerId,
res_model: "res.partner",
res_id: serverState.partnerId,
subtype_ids: [subtypeId],
});
await start();
await openFormView("res.partner", serverState.partnerId);
await click(".o-mail-Followers-button");
await click("[title='Edit subscription']");
await contains(
`.o-mail-FollowerSubtypeDialog-subtype[data-follower-subtype-id='${subtypeId}'] label`,
{ text: "TestSubtype" }
);
await contains(
`.o-mail-FollowerSubtypeDialog-subtype[data-follower-subtype-id='${subtypeId}'] input[type='checkbox']:checked`
);
});
test("simplest layout of a not followed subtype", async () => {
const pyEnv = await startServer();
const subtypeId = pyEnv["mail.message.subtype"].create({
default: true,
name: "TestSubtype",
});
pyEnv["mail.followers"].create({
display_name: "François Perusse",
partner_id: serverState.partnerId,
res_model: "res.partner",
res_id: serverState.partnerId,
});
await start();
await openFormView("res.partner", serverState.partnerId);
await click(".o-mail-Followers-button");
await click("[title='Edit subscription']");
await contains(
`.o-mail-FollowerSubtypeDialog-subtype[data-follower-subtype-id='${subtypeId}'] input[type='checkbox']:not(:checked)`
);
});
test("toggle follower subtype checkbox", async () => {
const pyEnv = await startServer();
const subtypeId = pyEnv["mail.message.subtype"].create({
default: true,
name: "TestSubtype",
});
pyEnv["mail.followers"].create({
display_name: "François Perusse",
partner_id: serverState.partnerId,
res_model: "res.partner",
res_id: serverState.partnerId,
});
await start();
await openFormView("res.partner", serverState.partnerId);
await click(".o-mail-Followers-button");
await click("[title='Edit subscription']");
await contains(
`.o-mail-FollowerSubtypeDialog-subtype[data-follower-subtype-id='${subtypeId}'] input[type='checkbox']:not(:checked)`
);
await click(
`.o-mail-FollowerSubtypeDialog-subtype[data-follower-subtype-id='${subtypeId}'] input[type='checkbox']`
);
await contains(
`.o-mail-FollowerSubtypeDialog-subtype[data-follower-subtype-id='${subtypeId}'] input[type='checkbox']:checked`
);
await click(
`.o-mail-FollowerSubtypeDialog-subtype[data-follower-subtype-id='${subtypeId}'] input[type='checkbox']`
);
await contains(
`.o-mail-FollowerSubtypeDialog-subtype[data-follower-subtype-id='${subtypeId}'] input[type='checkbox']:not(:checked)`
);
});
test("follower subtype apply", async () => {
const pyEnv = await startServer();
const subtypeId1 = pyEnv["mail.message.subtype"].create({
default: true,
name: "TestSubtype1",
});
const subtypeId2 = pyEnv["mail.message.subtype"].create({
default: true,
name: "TestSubtype2",
});
pyEnv["mail.followers"].create({
display_name: "François Perusse",
partner_id: serverState.partnerId,
res_model: "res.partner",
res_id: serverState.partnerId,
subtype_ids: [subtypeId1],
});
await start();
await openFormView("res.partner", serverState.partnerId);
await click(".o-mail-Followers-button");
await click("[title='Edit subscription']");
await contains(
`.o-mail-FollowerSubtypeDialog-subtype[data-follower-subtype-id='${subtypeId1}'] input[type='checkbox']:checked`
);
await contains(
`.o-mail-FollowerSubtypeDialog-subtype[data-follower-subtype-id='${subtypeId2}'] input[type='checkbox']:not(:checked)`
);
await click(
`.o-mail-FollowerSubtypeDialog-subtype[data-follower-subtype-id='${subtypeId1}'] input[type='checkbox']`
);
await contains(
`.o-mail-FollowerSubtypeDialog-subtype[data-follower-subtype-id='${subtypeId1}'] input[type='checkbox']:not(:checked)`
);
await click(
`.o-mail-FollowerSubtypeDialog-subtype[data-follower-subtype-id='${subtypeId2}'] input[type='checkbox']`
);
await contains(
`.o-mail-FollowerSubtypeDialog-subtype[data-follower-subtype-id='${subtypeId2}'] input[type='checkbox']:checked`
);
await click(".modal-footer button", { text: "Apply" });
await contains(".o_notification", {
text: "The subscription preferences were successfully applied.",
});
});

View file

@ -0,0 +1,268 @@
import {
SIZES,
click,
contains,
defineMailModels,
insertText,
openFormView,
patchUiSize,
scroll,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { describe, expect, test } from "@odoo/hoot";
import { mockService, serverState } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
defineMailModels();
test.skip("Form view not scrolled when switching record", async () => {
// FIXME: test passed in test environment but in practice scroll are reset to 0
// HOOT matches behaviour in prod and shows tests not passing as expected
const pyEnv = await startServer();
const [partnerId_1, partnerId_2] = pyEnv["res.partner"].create([
{
description: [...Array(60).keys()].join("\n"),
display_name: "Partner 1",
},
{
description: [...Array(60).keys()].join("\n"),
display_name: "Partner 2",
},
]);
const messages = [...Array(60).keys()].map((id) => {
return {
body: "not empty",
model: "res.partner",
res_id: id < 29 ? partnerId_1 : partnerId_2,
};
});
pyEnv["mail.message"].create(messages);
patchUiSize({ size: SIZES.LG });
await start();
await openFormView("res.partner", partnerId_1, {
arch: `
<form string="Partners">
<sheet>
<field name="name"/>
<field name="description"/>
</sheet>
<chatter/>
</form>`,
resIds: [partnerId_1, partnerId_2],
});
await contains(".o-mail-Message", { count: 29 });
await contains(".o_content", { scroll: 0 });
await scroll(".o_content", 150);
await click(".o_pager_next");
await contains(".o-mail-Message", { count: 30 });
await contains(".o_content", { scroll: 150 });
await scroll(".o_content", 0);
await click(".o_pager_previous");
await contains(".o-mail-Message", { count: 29 });
await contains(".o_content", { scroll: 0 });
});
test("Attachments that have been unlinked from server should be visually unlinked from record", async () => {
// Attachments that have been fetched from a record at certain time and then
// removed from the server should be reflected on the UI when the current
// partner accesses this record again.
const pyEnv = await startServer();
const [partnerId_1, partnerId_2] = pyEnv["res.partner"].create([
{ display_name: "Partner1" },
{ display_name: "Partner2" },
]);
const [attachmentId_1] = pyEnv["ir.attachment"].create([
{
mimetype: "text.txt",
res_id: partnerId_1,
res_model: "res.partner",
},
{
mimetype: "text.txt",
res_id: partnerId_1,
res_model: "res.partner",
},
]);
await start();
await openFormView("res.partner", partnerId_1, {
arch: `
<form string="Partners">
<sheet>
<field name="name"/>
</sheet>
<chatter/>
</form>`,
resId: partnerId_1,
resIds: [partnerId_1, partnerId_2],
});
await contains("button[aria-label='Attach files']", { text: "2" });
// The attachment links are updated on (re)load,
// so using pager is a way to reload the record "Partner1".
await click(".o_pager_next");
await contains("button[aria-label='Attach files']:not(:has(sup))");
// Simulate unlinking attachment 1 from Partner 1.
pyEnv["ir.attachment"].write([attachmentId_1], { res_id: 0 });
await click(".o_pager_previous");
await contains("button[aria-label='Attach files']", { text: "1" });
});
test("ellipsis button is not duplicated when switching from read to edit mode", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
pyEnv["mail.message"].create({
author_id: partnerId,
// "data-o-mail-quote" added by server is intended to be compacted in ellipsis block
body: `
<div>
Dear Joel Willis,<br>
Thank you for your enquiry.<br>
If you have any questions, please let us know.
<br><br>
Thank you,<br>
<div data-o-mail-quote="1">-- <br data-o-mail-quote="1">
System
</div>
</div>`,
model: "res.partner",
res_id: partnerId,
});
await start();
await openFormView("res.partner", partnerId, {
arch: `
<form string="Partners">
<sheet>
<field name="name"/>
</sheet>
<chatter/>
</form>`,
});
await contains(".o-mail-Chatter");
await contains(".o-mail-Message");
await contains(".o-mail-ellipsis");
});
test("[TECHNICAL] unfolded ellipsis button should not fold on message click besides that button", async () => {
// message click triggers a re-render. Before writing of this test, the
// insertion of ellipsis button were done during render. This meant
// any re-render would re-insert the ellipsis button. If some buttons
// were unfolded, any re-render would fold them again.
//
// This previous behavior is undesirable, and results to bothersome UX
// such as inability to copy/paste unfolded message content due to click
// from text selection automatically folding all ellipsis buttons.
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ display_name: "Someone" });
pyEnv["mail.message"].create({
author_id: partnerId,
// "data-o-mail-quote" added by server is intended to be compacted in ellipsis block
body: `
<div>
Dear Joel Willis,<br>
Thank you for your enquiry.<br>
If you have any questions, please let us know.
<br><br>
Thank you,<br>
<span data-o-mail-quote="1">-- <br data-o-mail-quote="1">
System
</span>
</div>`,
model: "res.partner",
res_id: partnerId,
});
await start();
await openFormView("res.partner", partnerId, {
arch: `
<form string="Partners">
<sheet>
<field name="name"/>
</sheet>
<chatter/>
</form>`,
});
expect(".o-mail-Message-body span").toHaveCount(0);
await click(".o-mail-ellipsis");
expect(".o-mail-Message-body span").toHaveText('--\nSystem')
await click(".o-mail-Message");
expect(".o-mail-Message-body span").toHaveCount(1);
});
test("ellipsis button on message of type notification", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
pyEnv["mail.message"].create({
author_id: partnerId,
// "data-o-mail-quote" enables ellipsis block
body: `
<div>
Dear Joel Willis,<br>
Thank you for your enquiry.<br>
If you have any questions, please let us know.
<br><br>
Thank you,<br>
<span data-o-mail-quote="1">-- <br data-o-mail-quote="1">
System
</span>
</div>`,
model: "res.partner",
res_id: partnerId,
message_type: "notification",
});
await start();
await openFormView("res.partner", partnerId, {
arch: `
<form string="Partners">
<sheet>
<field name="name"/>
</sheet>
<chatter/>
</form>`,
});
await contains(".o-mail-ellipsis");
});
test("read more/less should appear only once for the signature", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
mockService("action", {
doAction(action, { onClose }) {
if (action.name === "Compose Email") {
// Simulate message post of full composer
pyEnv["mail.message"].create({
body: action.context.default_body.toString(),
model: action.context.default_model,
res_id: action.context.default_res_ids[0],
});
return onClose(undefined);
}
return super.doAction(...arguments);
},
});
// Yes you can get this kind of signature by playing with the html editor
pyEnv["res.users"].write(serverState.userId, {
signature: `
<div>
<span data-o-mail-quote="1">
--
</span>
</div>
<div data-o-mail-quote="1">
Signature !
</div>
<div>
<br data-o-mail-quote="1">
</div>
`,
});
await start();
await openFormView("res.partner", partnerId);
await contains(".o-mail-Chatter");
await click(".o-mail-Chatter-sendMessage");
await insertText(".o-mail-Composer-input", "Example Body");
await click("[name='open-full-composer']");
await contains(".o-mail-Message-body", { text: "Example Body", count: 1 });
expect(".o-mail-Message .o-signature-container button.o-mail-ellipsis").toHaveCount(1);
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,303 @@
import { setSelection } from "@html_editor/../tests/_helpers/selection";
import { insertText } from "@html_editor/../tests/_helpers/user_actions";
import { FileSelector } from "@html_editor/main/media/media_dialog/file_selector";
import { uploadService } from "@html_editor/main/media/media_dialog/upload_progress_toast/upload_service";
import { HtmlComposerMessageField } from "@mail/views/web/fields/html_composer_message_field/html_composer_message_field";
import { beforeEach, describe, expect, test } from "@odoo/hoot";
import {
manuallyDispatchProgrammaticEvent,
press,
queryAll,
queryAllTexts,
queryOne,
waitFor,
waitForNone,
} from "@odoo/hoot-dom";
import { Deferred, animationFrame } from "@odoo/hoot-mock";
import {
contains,
makeMockServer,
mockService,
mountView,
mountViewInDialog,
onRpc,
patchWithCleanup,
serverState,
} from "@web/../tests/web_test_helpers";
import {
click,
defineMailModels,
mailModels,
openFormView,
openView,
registerArchs,
start,
startServer,
} from "../mail_test_helpers";
// Need this hack to use the arch in mountView(...)
mailModels.MailComposeMessage._views = {};
defineMailModels([]);
let htmlEditor;
beforeEach(() => {
patchWithCleanup(HtmlComposerMessageField.prototype, {
onEditorLoad(editor) {
htmlEditor = editor;
return super.onEditorLoad(...arguments);
},
});
});
test("media dialog: upload", async function () {
const isUploaded = new Deferred();
patchWithCleanup(FileSelector.prototype, {
async onUploaded() {
await super.onUploaded(...arguments);
isUploaded.resolve();
},
});
mockService("upload", uploadService);
const { env } = await makeMockServer();
const resId = env["mail.compose.message"].create({
display_name: "Some Composer",
body: "Hello",
attachment_ids: [],
});
let newAttachmentId;
onRpc("web_save", ({ args }) => {
expect.step("web_save");
const createVals = args[1];
expect(createVals.attachment_ids[0][0]).toBe(4); // link command
expect(createVals.attachment_ids[0][1]).toBe(newAttachmentId); // on attachment id "5"
});
onRpc("/html_editor/attachment/add_data", () => {
const attachment = {
name: "test.jpg",
description: false,
mimetype: "image/jpeg",
checksum: "7951a43bbfb08fd742224ada280913d1897b89ab",
url: false,
type: "binary",
res_id: 0,
res_model: "mail.compose.message",
public: false,
access_token: false,
image_src: "/web/image/1-a0e63e61/test.jpg",
image_width: 1,
image_height: 1,
original_id: false,
};
newAttachmentId = env["ir.attachment"].create(attachment);
attachment.id = newAttachmentId;
return attachment;
});
onRpc("ir.attachment", "generate_access_token", () => ["129a52e1-6bf2-470a-830e-8e368b022e13"]);
await mountView({
type: "form",
resId,
resModel: "mail.compose.message",
arch: `
<form>
<field name="body" type="html" widget="html_composer_message"/>
<field name="attachment_ids" widget="many2many_binary"/>
</form>`,
});
const anchorNode = queryOne(".odoo-editor-editable div.o-paragraph");
setSelection({ anchorNode, anchorOffset: 0 });
// Open media dialog
await animationFrame();
await insertText(htmlEditor, "/image");
await press("Enter");
await animationFrame();
// upload test
const fileInputs = queryAll(".o_select_media_dialog input.d-none.o_file_input");
const fileB64 =
"/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD3+iiigD//2Q==";
const fileBytes = new Uint8Array(
atob(fileB64)
.split("")
.map((char) => char.charCodeAt(0))
);
// redefine 'files' so we can put mock data in through js
fileInputs.forEach((input) =>
Object.defineProperty(input, "files", {
value: [new File(fileBytes, "test.jpg", { type: "image/jpeg" })],
})
);
fileInputs.forEach((input) => {
manuallyDispatchProgrammaticEvent(input, "change");
});
expect("[name='attachment_ids'] .o_attachment[title='test.jpg']").toHaveCount(0);
await isUploaded;
await animationFrame();
expect("[name='attachment_ids'] .o_attachment[title='test.jpg']").toHaveCount(1);
await contains(".o_form_button_save").click();
await expect.waitForSteps(["web_save"]);
});
test("mention a partner", async () => {
onRpc("res.partner", "get_mention_suggestions", ({ kwargs }) => {
expect.step(`get_mention_suggestions: ${kwargs.search}`);
});
const pyEnv = await startServer();
registerArchs({
"mail.compose.message,false,form": `
<form>
<field name="body" type="html" widget="html_composer_message"/>
</form>`,
});
const composerId = pyEnv["mail.compose.message"].create({
subject: "Greetings",
body: "<p><br></p>",
model: "res.partner",
});
await start();
await openView({
res_model: "mail.compose.message",
res_id: composerId,
views: [["mail.compose.message,false,form", "form"]],
});
const anchorNode = queryOne(`.odoo-editor-editable p`);
setSelection({ anchorNode, anchorOffset: 0 });
await insertText(htmlEditor, "@");
await animationFrame();
expect(".overlay .search input[placeholder='Search for a user...']").toBeFocused();
expect(".overlay .o-mail-NavigableList .o-mail-NavigableList-item").toHaveCount(0);
await press("a");
await waitFor(".overlay .o-mail-NavigableList .o-mail-NavigableList-item");
expect(queryAllTexts(".overlay .o-mail-NavigableList .o-mail-NavigableList-item")).toEqual([
"Mitchell Admin",
]);
expect.verifySteps(["get_mention_suggestions: a"]);
await press("enter");
expect("[name='body'] .odoo-editor-editable").toHaveInnerHTML(`
<p>
<a target="_blank" data-oe-protected="true" contenteditable="false" href="https://www.hoot.test/odoo/res.partner/17" class="o_mail_redirect" data-oe-id="17" data-oe-model="res.partner">
@Mitchell Admin
</a>
</p>`);
});
test("mention a channel", async () => {
onRpc("discuss.channel", "get_mention_suggestions", ({ kwargs }) => {
expect.step(`get_mention_suggestions: ${kwargs.search}`);
});
await mountViewInDialog({
type: "form",
resModel: "mail.compose.message",
arch: `
<form>
<field name="body" type="html" widget="html_composer_message"/>
</form>`,
});
const anchorNode = queryOne(`[name='body'] .odoo-editor-editable div.o-paragraph`);
setSelection({ anchorNode, anchorOffset: 0 });
await insertText(htmlEditor, "#");
await animationFrame();
expect(".overlay .search input[placeholder='Search for a channel...']").toBeFocused();
expect(".overlay .o-mail-NavigableList .o-mail-NavigableList-item").toHaveCount(0);
await press("a");
await animationFrame();
expect.verifySteps(["get_mention_suggestions: a"]);
});
describe("Remove attachments", () => {
beforeEach(() => {
mailModels.MailComposeMessage._views = {
"form,false": `
<form js_class="mail_composer_form">
<field name="body" type="html" widget="html_composer_message"/>
<field name="attachment_ids" widget="mail_composer_attachment_list"/>
</form>`,
};
});
test("should remove file from html editor if removed from attachment list", async () => {
mockService("uploadLocalFiles", {
async upload() {
expect.step("File Uploaded");
return [{ id: 1, name: "file.txt", public: true, checksum: "123" }];
},
});
await start();
await openFormView("res.partner", serverState.partnerId);
await click("button", { text: "Log note" });
await click("button[title='Open Full Composer']");
await waitFor(".odoo-editor-editable");
const anchorNode = queryOne(".odoo-editor-editable div.o-paragraph");
setSelection({ anchorNode, anchorOffset: 0 });
await insertText(htmlEditor, "/file");
await press("Enter");
await expect.waitForSteps(["File Uploaded"]);
await waitFor("[name='attachment_ids'] a:contains('file.txt')");
await waitFor(".odoo-editor-editable .o_file_box:has(a:contains('file.txt'))");
await click("[name='attachment_ids'] button:has(i.fa-times)");
await waitForNone("[name='attachment_ids'] a:contains('file.txt')");
await waitForNone(".odoo-editor-editable .o_file_box:has(a:contains('file.txt'))");
});
test("should remove image from html editor if removed from attachment list", async () => {
patchWithCleanup(FileSelector.prototype, {
async onUploaded() {
await super.onUploaded(...arguments);
expect.step("Image Uploaded");
},
});
onRpc("/html_editor/attachment/add_data", () => ({
id: 1,
name: "test.jpg",
description: false,
mimetype: "image/jpeg",
checksum: "7951a43bbfb08fd742224ada280913d1897b89ab",
url: false,
type: "binary",
res_id: 0,
res_model: "mail.compose.message",
public: false,
access_token: false,
image_src: "/web/image/1-a0e63e61/test.jpg",
image_width: 1,
image_height: 1,
original_id: false,
}));
onRpc("ir.attachment", "generate_access_token", () => [
"129a52e1-6bf2-470a-830e-8e368b022e13",
]);
await start();
await openFormView("res.partner", serverState.partnerId);
await click("button", { text: "Log note" });
await click("button[title='Open Full Composer']");
await waitFor(".odoo-editor-editable");
const anchorNode = queryOne(".odoo-editor-editable div.o-paragraph");
setSelection({ anchorNode, anchorOffset: 0 });
await insertText(htmlEditor, "/image");
await press("Enter");
await animationFrame();
const fileInput = queryOne(".o_select_media_dialog input.d-none.o_file_input");
Object.defineProperty(fileInput, "files", {
value: [new File([], "test.jpg", { type: "image/jpeg" })],
});
manuallyDispatchProgrammaticEvent(fileInput, "change");
await expect.waitForSteps(["Image Uploaded"]);
await waitFor("[name='attachment_ids'] a:contains('test.jpg')");
await waitFor(".odoo-editor-editable img[data-attachment-id='1']");
await click("[name='attachment_ids'] button:has(i.fa-times)");
await waitForNone("[name='attachment_ids'] a:contains('test.jpg')");
await waitForNone(".odoo-editor-editable img[data-attachment-id='1']");
});
});

View file

@ -0,0 +1,249 @@
import {
click,
contains,
defineMailModels,
insertText,
onRpcBefore,
openFormView,
registerArchs,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { describe, expect, test } from "@odoo/hoot";
import { Deferred, tick } from "@odoo/hoot-mock";
import { asyncStep, mockService, waitForSteps } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
defineMailModels();
const archs = {
"res.fake,false,form": `
<form string="Fake">
<sheet></sheet>
<chatter/>
</form>`,
"res.partner,false,form": `
<form string="Partner">
<sheet>
<field name="name"/>
<field name="email"/>
<field name="phone"/>
</sheet>
</form>`,
};
test("Show 'Followers only' placeholder for recipients input when no recipient", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "test name 1", email: "test1@odoo.com" });
await start();
await openFormView("res.partner", partnerId);
await click("button", { text: "Send message" });
await contains(".o-mail-RecipientsInput .o-autocomplete--input[placeholder='Followers only']");
});
test("Opening full composer in 'send message' mode should copy selected suggested recipients", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({
name: "John Jane",
email: "john@jane.be",
});
const fakeId = pyEnv["res.fake"].create({
email_cc: "john@test.be",
phone: "123456789",
partner_ids: [partnerId],
});
const def = new Deferred();
mockService("action", {
async doAction(action) {
if (action?.res_model === "res.fake") {
return super.doAction(...arguments);
}
asyncStep("do-action");
expect(action.name).toBe("Compose Email");
expect(action.context.default_subtype_xmlid).toBe("mail.mt_comment");
expect(action.context.default_partner_ids).toHaveLength(2);
const [johnTestPartnerId] = pyEnv["res.partner"].search([
["email", "=", "john@test.be"],
]);
expect(action.context.default_partner_ids).toEqual([johnTestPartnerId, partnerId]);
def.resolve();
},
});
await start();
await openFormView("res.fake", fakeId);
await click("button", { text: "Send message" });
await contains(".o-mail-RecipientsInput .o_tag_badge_text:contains(John Jane)");
await contains(".o-mail-RecipientsInput .o_tag_badge_text:contains(john@test.be)");
await click("button[title='Open Full Composer']");
await def;
await waitForSteps(["do-action"]);
});
test("Opening full composer in 'log note' mode should not copy selected suggested recipients", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({
name: "John Jane",
email: "john@jane.be",
});
const fakeId = pyEnv["res.fake"].create({
email_cc: "john@test.be",
partner_ids: [partnerId],
});
const def = new Deferred();
mockService("action", {
async doAction(action) {
if (action?.res_model === "res.fake") {
return super.doAction(...arguments);
}
asyncStep("do-action");
expect(action.name).toBe("Log note");
expect(action.context.default_subtype_xmlid).toBe("mail.mt_note");
expect(action.context.default_partner_ids).toBeEmpty();
def.resolve();
},
});
await start();
await openFormView("res.fake", fakeId);
await click("button", { text: "Send message" });
await contains(".o-mail-RecipientsInput .o_tag_badge_text:contains(John Jane)");
await contains(".o-mail-RecipientsInput .o_tag_badge_text:contains(john@test.be)");
await click("button", { text: "Log note" });
await click("button[title='Open Full Composer']");
await def;
await waitForSteps(["do-action"]);
});
test("Check that a partner is created for new followers when sending a message", async () => {
const pyEnv = await startServer();
const [partnerId, partnerId_2] = pyEnv["res.partner"].create([
{ name: "John Jane", email: "john@jane.be" },
{ name: "Peter Johnson", email: "peter@johnson.be" },
]);
const fakeId = pyEnv["res.fake"].create({
email_cc: "john@test.be",
partner_ids: [partnerId],
});
pyEnv["mail.followers"].create({
partner_id: partnerId_2,
email: "peter@johnson.be",
is_active: true,
res_id: fakeId,
res_model: "res.fake",
});
registerArchs(archs);
await start();
await openFormView("res.fake", fakeId);
await contains(".o-mail-Followers-counter", { text: "1" });
await click("button", { text: "Send message" });
await contains(".o-mail-RecipientsInput .o_tag_badge_text:contains(John Jane)");
await contains(".o-mail-RecipientsInput .o_tag_badge_text:contains(john@test.be)");
// Ensure that partner `john@test.be` is created while sending the message (not before)
const partners = pyEnv["res.partner"].search_read([["email", "=", "john@test.be"]]);
expect(partners).toHaveLength(0);
await insertText(".o-mail-Composer-input", "Dummy Message");
await click(".o-mail-Composer-send:enabled");
await contains(".o-mail-Followers-counter", { text: "1" });
});
test("suggest recipient on 'Send message' composer", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({
name: "Peter Johnson",
email: "peter@johnson.be",
});
const fakeId = pyEnv["res.fake"].create({ email_cc: "john@test.be" });
pyEnv["mail.followers"].create({
partner_id: partnerId,
email: "peter@johnson.be",
is_active: true,
res_id: fakeId,
res_model: "res.fake",
});
registerArchs(archs);
await start();
await openFormView("res.fake", fakeId);
await contains(".o-mail-Followers-counter", { text: "1" });
await click("button", { text: "Send message" });
await contains(".o-mail-RecipientsInput .o_tag_badge_text:contains(john@test.be)");
// Ensure that partner `john@test.be` is created before sending the message
expect(pyEnv["res.partner"].search_read([["email", "=", "john@test.be"]])).toHaveLength(0);
await insertText(".o-mail-Composer-input", "Dummy Message");
await click(".o-mail-Composer-send:enabled");
await tick();
expect(pyEnv["res.partner"].search_read([["email", "=", "john@test.be"]])).toHaveLength(1);
await contains(".o-mail-Followers-counter", { text: "1" });
});
test("suggested recipients should not be notified when posting an internal note", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({
name: "John Jane",
email: "john@jane.be",
});
const fakeId = pyEnv["res.fake"].create({ partner_ids: [partnerId] });
onRpcBefore("/mail/message/post", (args) => {
asyncStep("message_post");
expect(args.post_data.partner_ids).toBeEmpty();
});
await start();
await openFormView("res.fake", fakeId);
await click("button", { text: "Log note" });
await insertText(".o-mail-Composer-input", "Dummy Message");
await click(".o-mail-Composer-send:enabled");
await contains(".o-mail-Message");
await waitForSteps(["message_post"]);
});
test("suggested recipients without name should show display_name instead", async () => {
const pyEnv = await startServer();
const [partner1, partner2] = pyEnv["res.partner"].create([
{ name: "Test Partner" },
// Partner without name
{ type: "invoice" },
]);
pyEnv["res.partner"].write([partner2], { parent_id: partner1 });
const fakeId = pyEnv["res.fake"].create({ partner_ids: [partner2] });
registerArchs(archs);
await start();
await openFormView("res.fake", fakeId);
await click("button", { text: "Send message" });
await contains(".o-mail-RecipientsInput .o_tag_badge_text", { text: "Test Partner, Invoice" });
});
test("update email for the partner on the fly", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({
name: "John Jane",
});
const fakeId = pyEnv["res.fake"].create({ partner_ids: [partnerId] });
registerArchs(archs);
await start();
await openFormView("res.fake", fakeId);
await click("button", { text: "Send message" });
await insertText(".o-mail-RecipientsInputTagsListPopover input", "john@jane.be");
await click(".o-mail-RecipientsInputTagsListPopover .btn-primary");
await insertText(".o-mail-Composer-input", "Dummy Message");
await click(".o-mail-Composer-send:enabled");
await contains(".o-mail-Message");
await contains(".o-mail-Followers-counter", { text: "0" });
});
test("suggested recipients should not be added as follower when posting a message", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({
name: "John Jane",
email: "john@jane.be",
});
const fakeId = pyEnv["res.fake"].create({ partner_ids: [partnerId] });
registerArchs(archs);
await start();
await openFormView("res.fake", fakeId);
await contains(".o-mail-Followers-counter", { text: "0" });
await click("button", { text: "Send message" });
await insertText(".o-mail-Composer-input", "Dummy Message");
await click(".o-mail-Composer-send:enabled");
await contains(".o-mail-Message");
await contains(".o-mail-Followers-counter", { text: "0" });
});

View file

@ -0,0 +1,20 @@
import { defineMailModels, start } from "@mail/../tests/mail_test_helpers";
import { describe, expect, test } from "@odoo/hoot";
import { getService } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
defineMailModels();
test("Attachment model properties", async () => {
await start();
const attachment = getService("mail.store")["ir.attachment"].insert({
id: 750,
mimetype: "text/plain",
name: "test.txt",
});
expect(attachment.isText).toBe(true);
expect(attachment.isViewable).toBe(true);
expect(attachment.mimetype).toBe("text/plain");
expect(attachment.name).toBe("test.txt");
expect(attachment.extension).toBe("txt");
});

View file

@ -0,0 +1,17 @@
import { Action } from "@mail/core/common/action";
import { describe, expect, test } from "@odoo/hoot";
describe.current.tags("desktop");
test("store is correctly set on actions", async () => {
const storeSym = Symbol("STORE");
const ownerSym = Symbol("COMPONENT");
const action = new Action({
owner: ownerSym,
id: "test",
definition: {},
store: storeSym,
});
expect(action.store).toBe(storeSym);
});

View file

@ -0,0 +1,91 @@
import {
click,
contains,
defineMailModels,
insertText,
openFormView,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { describe, test } from "@odoo/hoot";
import { animationFrame } from "@odoo/hoot-dom";
import { getOrigin } from "@web/core/utils/urls";
describe.current.tags("desktop");
defineMailModels();
test("following internal link from chatter does not open chat window", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Jeanne" });
pyEnv["mail.message"].create({
body: `Created by <a href="#" data-oe-model="res.partner" data-oe-id="${pyEnv.user.partner_id}">Admin</a>`,
model: "res.partner",
res_id: partnerId,
});
await start();
await openFormView("res.partner", partnerId);
await contains(".o_last_breadcrumb_item", { text: "Jeanne" });
await click("a", { text: "Admin" });
await contains(".o_last_breadcrumb_item", { text: "Mitchell Admin" });
// Assert 0 chat windows not sufficient because not enough time for potential chat window opening.
// Let's open another chat window to give some time and assert only manually open chat window opens.
await contains(".o-mail-ChatWindow", { count: 0 });
await click(".o_menu_systray i[aria-label='Messages']");
await click("button", { text: "New Message" });
await insertText("input[placeholder='Search a conversation']", "abc");
await click("a", { text: "Create Channel" });
await contains(".o-mail-ChatWindow-header", { text: "abc" });
await contains(".o-mail-ChatWindow", { count: 1 });
});
test("message link shows error when the message is not known", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Alice" });
const url = `${getOrigin()}/mail/message/999999`;
pyEnv["mail.message"].create({
body: `Check this out <a class="o_message_redirect" href="${url}" data-oe-model="mail.message" data-oe-id="999999">${url}</a>`,
model: "res.partner",
res_id: partnerId,
});
await start();
await openFormView("res.partner", partnerId);
await click("a.o_message_redirect");
await contains(".o_notification:contains(This conversation isnt available.)");
});
test("same-thread message link does not open the thread again but highlights the message", async () => {
const pyEnv = await startServer();
const [aliceId, lenaId] = pyEnv["res.partner"].create([{ name: "Alice" }, { name: "Lena" }]);
const helloMessageId = pyEnv["mail.message"].create({
body: "Hello",
model: "res.partner",
res_id: aliceId,
});
const heyMessageId = pyEnv["mail.message"].create({
body: "Hey",
model: "res.partner",
res_id: lenaId,
});
const helloUrl = `${getOrigin()}/mail/message/${helloMessageId}`;
pyEnv["mail.message"].create({
body: `Check this out <a class="o_message_redirect" href="${helloUrl}" data-oe-model="mail.message" data-oe-id="${helloMessageId}">${helloUrl}</a>`,
model: "res.partner",
res_id: aliceId,
});
const heyUrl = `${getOrigin()}/mail/message/${heyMessageId}`;
pyEnv["mail.message"].create({
body: `Another thread <a class="o_message_redirect" href="${heyUrl}" data-oe-model="mail.message" data-oe-id="${heyMessageId}">${heyUrl}</a>`,
model: "res.partner",
res_id: aliceId,
});
await start();
await openFormView("res.partner", aliceId);
await click("a.o_message_redirect:contains(Alice)");
await contains(".o-mail-Message.o-highlighted:contains(Hello)");
await animationFrame(); // give enough time for the potential breadcrumb item to render
await contains(".breadcrumb-item", { count: 0 });
await click("a.o_message_redirect:contains(Lena)");
await contains(".o-mail-Message.o-highlighted:contains(Hey)");
await contains(".breadcrumb-item:contains(Alice)");
});

View file

@ -0,0 +1,81 @@
import { Message } from "@mail/core/common/message_model";
import { defineMailModels, start } from "@mail/../tests/mail_test_helpers";
import { expect, test } from "@odoo/hoot";
import {
asyncStep,
getService,
patchWithCleanup,
waitForSteps,
} from "@web/../tests/web_test_helpers";
defineMailModels();
test("store.insert can delete record", async () => {
await start();
const store = getService("mail.store");
store.insert({ "mail.message": [{ id: 1 }] });
expect(store["mail.message"].get({ id: 1 })?.id).toBe(1);
store.insert({ "mail.message": [{ id: 1, _DELETE: true }] });
expect(store["mail.message"].get({ id: 1 })?.id).toBe(undefined);
});
test("store.insert deletes record without creating it", async () => {
patchWithCleanup(Message, {
new() {
const message = super.new(...arguments);
asyncStep(`new-${message.id}`);
return message;
},
});
await start();
const store = getService("mail.store");
store.insert({ "mail.message": [{ id: 1, _DELETE: true }] });
await waitForSteps([]);
expect(store["mail.message"].get({ id: 1 })?.id).toBe(undefined);
store.insert({ "mail.message": [{ id: 2 }] });
await waitForSteps(["new-2"]);
});
test("store.insert deletes record after relation created it", async () => {
patchWithCleanup(Message, {
new() {
const message = super.new(...arguments);
asyncStep(`new-${message.id}`);
return message;
},
});
await start();
const store = getService("mail.store");
store.insert({
"mail.message": [{ id: 1, _DELETE: true }],
// they key coverage of the test is to have the relation listed after the delete
"mail.link.preview": [{ id: 1 }],
"mail.message.link.preview": [{ id: 1, link_preview_id: 1, message_id: 1 }],
});
await waitForSteps(["new-1"]);
expect(store["mail.message"].get({ id: 1 })?.id).toBe(undefined);
});
test("store.insert different PY model having same JS model", async () => {
await start();
const store = getService("mail.store");
const data = {
"discuss.channel": [
{ id: 1, name: "General" },
{ id: 2, name: "Sales" },
],
"mail.thread": [
{ id: 1, model: "discuss.channel" },
{ id: 3, name: "R&D", model: "discuss.channel" },
],
};
store.insert(data);
expect(store.Thread.records).toHaveLength(6); // 3 mailboxes + 3 channels
expect(Boolean(store.Thread.get({ id: 1, model: "discuss.channel" }))).toBe(true);
expect(Boolean(store.Thread.get({ id: 2, model: "discuss.channel" }))).toBe(true);
expect(Boolean(store.Thread.get({ id: 3, model: "discuss.channel" }))).toBe(true);
});

View file

@ -0,0 +1,47 @@
import { defineMailModels, start } from "@mail/../tests/mail_test_helpers";
import { describe, expect, test } from "@odoo/hoot";
import { markup } from "@odoo/owl";
import { deserializeDateTime, serializeDateTime } from "@web/core/l10n/dates";
import { getService, serverState } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
defineMailModels();
test("Message model properties", async () => {
await start();
const store = getService("mail.store");
store.Store.insert({
self_partner: { id: serverState.partnerId },
});
store.Thread.insert({
id: serverState.partnerId,
model: "res.partner",
name: "general",
});
store["ir.attachment"].insert({
id: 750,
mimetype: "text/plain",
name: "test.txt",
});
const message = store["mail.message"].insert({
attachment_ids: 750,
author_id: { id: 5, name: "Demo" },
body: markup`<p>Test</p>`,
date: deserializeDateTime("2019-05-05 10:00:00"),
id: 4000,
starred: true,
model: "res.partner",
thread: { id: serverState.partnerId, model: "res.partner" },
res_id: serverState.partnerId,
});
expect(message.body?.toString()).toBe("<p>Test</p>");
expect(serializeDateTime(message.date)).toBe("2019-05-05 10:00:00");
expect(message.id).toBe(4000);
expect(message.attachment_ids[0].name).toBe("test.txt");
expect(message.thread.id).toBe(serverState.partnerId);
expect(message.thread.name).toBe("general");
expect(message.author_id.id).toBe(5);
expect(message.author_id.name).toBe("Demo");
});

View file

@ -0,0 +1,381 @@
import { waitNotifications } from "@bus/../tests/bus_test_helpers";
import {
click,
contains,
defineMailModels,
insertText,
listenStoreFetch,
openDiscuss,
openFormView,
setupChatHub,
start,
startServer,
triggerHotkey,
waitStoreFetch,
} from "@mail/../tests/mail_test_helpers";
import { describe, test } from "@odoo/hoot";
import { click as hootClick, press, queryFirst } from "@odoo/hoot-dom";
import { mockDate } from "@odoo/hoot-mock";
import { Command, serverState, withUser } from "@web/../tests/web_test_helpers";
import { rpc } from "@web/core/network/rpc";
describe.current.tags("desktop");
defineMailModels();
test("keep new message separator when message is deleted", async () => {
const pyEnv = await startServer();
const generalId = pyEnv["discuss.channel"].create({ name: "General" });
pyEnv["mail.message"].create([
{
body: "message 0",
message_type: "comment",
model: "discuss.channel",
author_id: serverState.partnerId,
res_id: generalId,
},
{
body: "message 1",
message_type: "comment",
model: "discuss.channel",
author_id: serverState.partnerId,
res_id: generalId,
},
]);
await start();
await openDiscuss(generalId);
await contains(".o-mail-Message", { count: 2 });
queryFirst(".o-mail-Composer-input").blur();
await click("[title='Expand']", {
parent: [".o-mail-Message", { text: "message 0" }],
});
await click(".o-dropdown-item:contains('Mark as Unread')");
await contains(".o-mail-Thread-newMessage ~ .o-mail-Message", { text: "message 0" });
await click("[title='Expand']", {
parent: [".o-mail-Message", { text: "message 0" }],
});
await click(".o-dropdown-item:contains('Delete')");
await click(".modal button", { text: "Delete" });
await contains(".o-mail-Message", { text: "message 0", count: 0 });
await contains(".o-mail-Thread-newMessage ~ .o-mail-Message", { text: "message 1" });
});
test("new message separator is not shown if all messages are new", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
const bobPartnerId = pyEnv["res.partner"].create({ name: "Bob" });
for (let i = 0; i < 5; i++) {
pyEnv["mail.message"].create({
author_id: bobPartnerId,
body: `message ${i}`,
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
});
}
await start();
await openDiscuss(channelId);
await contains(".o-mail-Message", { count: 5 });
await contains(".o-mail-Thread-newMessage hr + span", { count: 0, text: "New" });
});
test("new message separator is shown after first mark as read, on receiving new message", async () => {
const pyEnv = await startServer();
const bobPartnerId = pyEnv["res.partner"].create({ name: "Bob" });
const bobUserId = pyEnv["res.users"].create({ name: "Bob", partner_id: bobPartnerId });
const channelId = pyEnv["discuss.channel"].create({
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: bobPartnerId }),
],
channel_type: "chat",
});
pyEnv["mail.message"].create({
author_id: bobPartnerId,
body: `Message 0`,
model: "discuss.channel",
res_id: channelId,
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-Message", { text: "Message 0" });
await contains(".o-mail-Thread-newMessage", { count: 0, text: "New" });
await withUser(bobUserId, () =>
rpc("/mail/message/post", {
post_data: {
body: "Message 1",
message_type: "comment",
subtype_xmlid: "mail.mt_comment",
},
thread_id: channelId,
thread_model: "discuss.channel",
})
);
await contains(".o-mail-Thread-newMessage ~ .o-mail-Message", { text: "Message 1" });
await contains(".o-mail-Thread-newMessage", { text: "New" });
});
test("keep new message separator until user goes back to the thread", async () => {
const pyEnv = await startServer();
pyEnv["res.users"].write(serverState.userId, { notification_type: "inbox" });
const partnerId = pyEnv["res.partner"].create({ name: "Foreigner partner" });
const channelId = pyEnv["discuss.channel"].create({
name: "test",
channel_member_ids: [
Command.create({ partner_id: partnerId }),
Command.create({ partner_id: serverState.partnerId }),
],
});
const messageIds = pyEnv["mail.message"].create([
{
author_id: partnerId,
body: "Message body 1",
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
},
{
author_id: partnerId,
body: "Message body 2",
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
},
]);
// simulate that there is at least one read message in the channel
const [memberId] = pyEnv["discuss.channel.member"].search([
["channel_id", "=", channelId],
["partner_id", "=", serverState.partnerId],
]);
pyEnv["discuss.channel.member"].write([memberId], { new_message_separator: messageIds[0] + 1 });
await start();
await openDiscuss(channelId);
await contains(".o-mail-Thread");
await contains(".o-mail-Thread-newMessage ~ .o-mail-Message", { text: "Message body 2" });
await contains(".o-mail-Thread-newMessage:contains('New')");
await hootClick(document.body); // Force "focusin" back on the textarea
await hootClick(".o-mail-Composer-input");
await waitNotifications([
"mail.record/insert",
(n) => n["discuss.channel.member"][0].new_message_separator,
]);
await hootClick(".o-mail-DiscussSidebar-item:contains(History)");
await contains(".o-mail-DiscussContent-threadName", { value: "History" });
await hootClick(".o-mail-DiscussSidebar-item:contains(test)");
await contains(".o-mail-DiscussContent-threadName", { value: "test" });
await contains(".o-mail-Message", { text: "Message body 2" });
await contains(".o-mail-Thread-newMessage:contains('New')", { count: 0 });
});
test("show new message separator on receiving new message when out of odoo focus", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Foreigner partner" });
const userId = pyEnv["res.users"].create({
name: "Foreigner user",
partner_id: partnerId,
});
const channelId = pyEnv["discuss.channel"].create({
channel_member_ids: [
Command.create({ message_unread_counter: 0, partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
channel_type: "channel",
name: "General",
});
const messageId = pyEnv["mail.message"].create({
body: "not empty",
model: "discuss.channel",
res_id: channelId,
});
// simulate that there is at least one read message in the channel
const [memberId] = pyEnv["discuss.channel.member"].search([
["channel_id", "=", channelId],
["partner_id", "=", serverState.partnerId],
]);
pyEnv["discuss.channel.member"].write([memberId], { new_message_separator: messageId + 1 });
await start();
await openDiscuss(channelId);
await contains(".o-mail-Thread");
await contains(".o-mail-Thread-newMessage:contains('New')", { count: 0 });
// simulate receiving a message
await withUser(userId, () =>
rpc("/mail/message/post", {
post_data: { body: "hu", message_type: "comment", subtype_xmlid: "mail.mt_comment" },
thread_id: channelId,
thread_model: "discuss.channel",
})
);
await contains(".o-mail-Message", { text: "hu" });
await contains(".o-mail-Thread-newMessage:contains('New')");
await contains(".o-mail-Thread-newMessage ~ .o-mail-Message", { text: "hu" });
});
test("keep new message separator until current user sends a message", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
await start();
await openDiscuss(channelId);
await insertText(".o-mail-Composer-input", "hello");
await triggerHotkey("Enter");
await contains(".o-mail-Message", { text: "hello" });
await click(".o-mail-Message [title='Expand']");
await click(".o-dropdown-item:contains('Mark as Unread')");
await contains(".o-mail-Thread-newMessage:contains('New')");
await insertText(".o-mail-Composer-input", "hey!");
await press("Enter");
await contains(".o-mail-Message", { count: 2 });
await contains(".o-mail-Thread-newMessage:contains('New')", { count: 0 });
});
test("keep new message separator when switching between chat window and discuss of same thread", async () => {
const pyEnv = await startServer();
pyEnv["discuss.channel"].create({ channel_type: "channel", name: "General" });
await start();
await click(".o_menu_systray i[aria-label='Messages']");
await click("button", { text: "General" });
await insertText(".o-mail-Composer-input", "Very important message!");
await triggerHotkey("Enter");
await click(".o-mail-Message [title='Expand']");
await click(".o-dropdown-item:contains('Mark as Unread')");
await contains(".o-mail-Thread-newMessage");
// dropdown requires an extra delay before click (because handler is registered in useEffect)
await contains("[title='Open Actions Menu']");
await click("[title='Open Actions Menu']");
await click(".o-dropdown-item", { text: "Open in Discuss" });
await contains(".o-mail-DiscussContent-threadName", { value: "General" });
await contains(".o-mail-Thread-newMessage");
await openFormView("res.partner", serverState.partnerId);
await contains(".o-mail-ChatWindow-header", { text: "General" });
await contains(".o-mail-Thread-newMessage");
});
test("show new message separator when message is received in chat window", async () => {
mockDate("2023-01-03 12:00:00"); // so that it's after last interest (mock server is in 2019 by default!)
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Demo" });
const userId = pyEnv["res.users"].create({ name: "Foreigner user", partner_id: partnerId });
const channelId = pyEnv["discuss.channel"].create({
channel_member_ids: [
Command.create({
unpin_dt: "2021-01-01 12:00:00",
last_interest_dt: "2021-01-01 10:00:00",
partner_id: serverState.partnerId,
}),
Command.create({ partner_id: partnerId }),
],
channel_type: "chat",
});
const messageId = pyEnv["mail.message"].create({
body: "not empty",
model: "discuss.channel",
res_id: channelId,
});
const [memberId] = pyEnv["discuss.channel.member"].search([
["channel_id", "=", channelId],
["partner_id", "=", serverState.partnerId],
]);
pyEnv["discuss.channel.member"].write([memberId], { new_message_separator: messageId + 1 });
setupChatHub({ opened: [channelId] });
await start();
// simulate receiving a message
withUser(userId, () =>
rpc("/mail/message/post", {
post_data: { body: "hu", message_type: "comment" },
thread_id: channelId,
thread_model: "discuss.channel",
})
);
await contains(".o-mail-ChatWindow");
await contains(".o-mail-Message", { count: 2 });
await contains(".o-mail-Thread-newMessage:contains('New'):contains('New')");
await contains(".o-mail-Thread-newMessage + .o-mail-Message", { text: "hu" });
});
test("show new message separator when message is received while chat window is closed", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Demo" });
const userId = pyEnv["res.users"].create({
name: "Foreigner user",
partner_id: partnerId,
});
const channelId = pyEnv["discuss.channel"].create({
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
channel_type: "chat",
});
const messageId = pyEnv["mail.message"].create({
body: "not empty",
model: "discuss.channel",
res_id: channelId,
});
// simulate that there is at least one read message in the channel
const [memberId] = pyEnv["discuss.channel.member"].search([
["channel_id", "=", channelId],
["partner_id", "=", serverState.partnerId],
]);
pyEnv["discuss.channel.member"].write([memberId], { new_message_separator: messageId + 1 });
setupChatHub({ opened: [channelId] });
listenStoreFetch("init_messaging");
await start();
await waitStoreFetch("init_messaging");
await click(".o-mail-ChatWindow-header [title*='Close Chat Window']");
await contains(".o-mail-ChatWindow", { count: 0 });
// send after init_messaging because bus subscription is done after init_messaging
// simulate receiving a message
await withUser(userId, () =>
rpc("/mail/message/post", {
post_data: { body: "hu", message_type: "comment" },
thread_id: channelId,
thread_model: "discuss.channel",
})
);
await contains(".o-mail-ChatBubble");
await contains(".o-mail-ChatBubble-counter", { text: "1" });
await click(".o-mail-ChatBubble");
await contains(".o-mail-Thread-newMessage:contains('New')");
});
test("only show new message separator in its thread", async () => {
// when a message acts as the reference for displaying new message separator,
// this should applies only when vieweing the message in its thread.
const pyEnv = await startServer();
pyEnv["res.users"].write(serverState.userId, { notification_type: "inbox" });
const demoPartnerId = pyEnv["res.partner"].create({ name: "Demo" });
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
const messageIds = pyEnv["mail.message"].create([
{
author_id: demoPartnerId,
body: "Hello",
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
},
{
author_id: demoPartnerId,
body: "@Mitchell Admin",
attachment_ids: [],
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
needaction: true,
},
]);
// simulate that there is at least one read message in the channel
const [memberId] = pyEnv["discuss.channel.member"].search([
["channel_id", "=", channelId],
["partner_id", "=", serverState.partnerId],
]);
pyEnv["discuss.channel.member"].write([memberId], { new_message_separator: messageIds[0] + 1 });
await start();
await openDiscuss(channelId);
await contains(".o-mail-Thread-newMessage ~ .o-mail-Message", { text: "@Mitchell Admin" });
await click(".o-mail-DiscussSidebar-item", { text: "Inbox" });
await contains(".o-mail-DiscussContent-threadName", { value: "Inbox" });
await contains(".o-mail-Message", { text: "@Mitchell Admin" });
await contains(".o-mail-Thread-newMessage ~ .o-mail-Message", {
count: 0,
text: "@Mitchell Admin",
});
});

View file

@ -0,0 +1,63 @@
import {
contains,
defineMailModels,
setupChatHub,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { describe, test } from "@odoo/hoot";
import { Command, getService, serverState } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
defineMailModels();
test("openChat: display notification for partner without user", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
await start();
await getService("mail.store").openChat({ partnerId });
await contains(".o_notification:has(.o_notification_bar.bg-info)", {
text: "You can only chat with partners that have a dedicated user.",
});
});
test("openChat: display notification for wrong user", async () => {
const pyEnv = await startServer();
pyEnv["res.users"].create({});
await start();
// userId not in the server data
await getService("mail.store").openChat({ userId: 4242 });
await contains(".o_notification:has(.o_notification_bar.bg-warning)", {
text: "You can only chat with existing users.",
});
});
test("openChat: open new chat for user", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
pyEnv["res.users"].create({ partner_id: partnerId });
await start();
await contains(".o-mail-ChatHub");
await contains(".o-mail-ChatWindow", { count: 0 });
getService("mail.store").openChat({ partnerId });
await contains(".o-mail-ChatWindow");
});
test.tags("focus required");
test("openChat: open existing chat for user", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
pyEnv["res.users"].create({ partner_id: partnerId });
const channelId = pyEnv["discuss.channel"].create({
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
channel_type: "chat",
});
setupChatHub({ opened: [channelId] });
await start();
await contains(".o-mail-ChatWindow .o-mail-Composer-input:not(:focus)");
getService("mail.store").openChat({ partnerId });
await contains(".o-mail-ChatWindow .o-mail-Composer-input:focus");
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,214 @@
import { HIGHLIGHT_CLASS, searchHighlight } from "@mail/core/common/message_search_hook";
import {
SIZES,
click,
contains,
defineMailModels,
insertText,
openDiscuss,
openFormView,
patchUiSize,
start,
startServer,
triggerHotkey,
} from "@mail/../tests/mail_test_helpers";
import { describe, expect, test } from "@odoo/hoot";
import { markup } from "@odoo/owl";
import { serverState } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
defineMailModels();
test("Search highlight", async () => {
const testCases = [
{
input: markup`test odoo`,
output: `test <span class="${HIGHLIGHT_CLASS}">odoo</span>`,
searchTerm: "odoo",
},
{
input: markup`<a href="https://www.odoo.com">https://www.odoo.com</a>`,
output: `<a href="https://www.odoo.com">https://www.<span class="${HIGHLIGHT_CLASS}">odoo</span>.com</a>`,
searchTerm: "odoo",
},
{
input: '<a href="https://www.odoo.com">https://www.odoo.com</a>',
output: `&lt;a href="https://www.<span class="${HIGHLIGHT_CLASS}">odoo</span>.com"&gt;https://www.<span class="${HIGHLIGHT_CLASS}">odoo</span>.com&lt;/a&gt;`,
searchTerm: "odoo",
},
{
input: markup`<a href="https://www.odoo.com">Odoo</a>`,
output: `<a href="https://www.odoo.com"><span class="${HIGHLIGHT_CLASS}">Odoo</span></a>`,
searchTerm: "odoo",
},
{
input: markup`<a href="https://www.odoo.com">Odoo</a> Odoo is a free software`,
output: `<a href="https://www.odoo.com"><span class="${HIGHLIGHT_CLASS}">Odoo</span></a> <span class="${HIGHLIGHT_CLASS}">Odoo</span> is a free software`,
searchTerm: "odoo",
},
{
input: markup`odoo is a free software`,
output: `<span class="${HIGHLIGHT_CLASS}">odoo</span> is a free software`,
searchTerm: "odoo",
},
{
input: markup`software ODOO is a free`,
output: `software <span class="${HIGHLIGHT_CLASS}">ODOO</span> is a free`,
searchTerm: "odoo",
},
{
input: markup`<ul>
<li>Odoo</li>
<li><a href="https://odoo.com">Odoo ERP</a> Best ERP</li>
</ul>`,
output: `<ul>
<li><span class="${HIGHLIGHT_CLASS}">Odoo</span></li>
<li><a href="https://odoo.com"><span class="${HIGHLIGHT_CLASS}">Odoo</span> ERP</a> Best ERP</li>
</ul>`,
searchTerm: "odoo",
},
{
input: markup`test <strong>Odoo</strong> test`,
output: `<span class="${HIGHLIGHT_CLASS}">test</span> <strong><span class="${HIGHLIGHT_CLASS}">Odoo</span></strong> <span class="${HIGHLIGHT_CLASS}">test</span>`,
searchTerm: "odoo test",
},
{
input: markup`test <br> test`,
output: `<span class="${HIGHLIGHT_CLASS}">test</span> <br> <span class="${HIGHLIGHT_CLASS}">test</span>`,
searchTerm: "odoo test",
},
{
input: markup`<strong>test</strong> test`,
output: `<strong><span class="${HIGHLIGHT_CLASS}">test</span></strong> <span class="${HIGHLIGHT_CLASS}">test</span>`,
searchTerm: "test",
},
{
input: markup`<strong>a</strong> test`,
output: `<strong><span class="${HIGHLIGHT_CLASS}">a</span></strong> <span class="${HIGHLIGHT_CLASS}">test</span>`,
searchTerm: "a test",
},
{
input: markup`&amp;amp;`,
output: `<span class="${HIGHLIGHT_CLASS}">&amp;amp;</span>`,
searchTerm: "&amp;",
},
{
input: markup`&amp;amp;`,
output: `<span class="${HIGHLIGHT_CLASS}">&amp;</span>amp;`,
searchTerm: "&",
},
{
input: markup`<strong>test</strong> hello`,
output: `<strong><span class="${HIGHLIGHT_CLASS}">test</span></strong> <span class="${HIGHLIGHT_CLASS}">hello</span>`,
searchTerm: "test hello",
},
{
input: markup`<p>&lt;strong&gt;test&lt;/strong&gt; hello</p>`,
output: `<p>&lt;strong&gt;<span class="${HIGHLIGHT_CLASS}">test</span>&lt;/strong&gt; <span class="${HIGHLIGHT_CLASS}">hello</span></p>`,
searchTerm: "test hello",
},
];
for (const { input, output, searchTerm } of testCases) {
expect(searchHighlight(searchTerm, input).toString()).toBe(output);
}
});
test("Display highlighted search in chatter", async () => {
patchUiSize({ size: SIZES.XXL });
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
pyEnv["mail.message"].create({
body: "not empty",
model: "res.partner",
res_id: partnerId,
});
await start();
await openFormView("res.partner", partnerId);
await click("[title='Search Messages']");
await insertText(".o_searchview_input", "empty");
triggerHotkey("Enter");
await contains(`.o-mail-SearchMessageResult .o-mail-Message span.${HIGHLIGHT_CLASS}`);
});
test("Display multiple highlighted search in chatter", async () => {
patchUiSize({ size: SIZES.XXL });
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
pyEnv["mail.message"].create({
body: "not test empty",
model: "res.partner",
res_id: partnerId,
});
await start();
await openFormView("res.partner", partnerId);
await click("[title='Search Messages']");
await insertText(".o_searchview_input", "not empty");
triggerHotkey("Enter");
await contains(`.o-mail-SearchMessageResult .o-mail-Message span.${HIGHLIGHT_CLASS}`, {
count: 2,
});
});
test("Display highlighted search in Discuss", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
pyEnv["mail.message"].create({
author_id: serverState.partnerId,
body: "not empty",
attachment_ids: [],
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-Message");
await click("button[title='Search Messages']");
await insertText(".o_searchview_input", "empty");
triggerHotkey("Enter");
await contains(`.o-mail-SearchMessagesPanel .o-mail-Message span.${HIGHLIGHT_CLASS}`);
});
test("Display multiple highlighted search in Discuss", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
pyEnv["mail.message"].create({
author_id: serverState.partnerId,
body: "not prout empty",
attachment_ids: [],
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-Message");
await click("button[title='Search Messages']");
await insertText(".o_searchview_input", "not empty");
triggerHotkey("Enter");
await contains(`.o-mail-SearchMessagesPanel .o-mail-Message span.${HIGHLIGHT_CLASS}`, {
count: 2,
});
});
test("Display highlighted with escaped character must ignore them", async () => {
patchUiSize({ size: SIZES.XXL });
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
pyEnv["mail.message"].create({
body: "<p>&lt;strong&gt;test&lt;/strong&gt; hello</p>",
model: "res.partner",
res_id: partnerId,
});
await start();
await openFormView("res.partner", partnerId);
await click("[title='Search Messages']");
await insertText(".o_searchview_input", "test hello");
triggerHotkey("Enter");
await contains(`.o-mail-SearchMessageResult .o-mail-Message span.${HIGHLIGHT_CLASS}`, {
count: 2,
});
await contains(`.o-mail-Message-body`, { text: "<strong>test</strong> hello" });
});

View file

@ -0,0 +1,197 @@
import {
click,
contains,
defineMailModels,
insertText,
openDiscuss,
start,
startServer,
triggerHotkey,
} from "@mail/../tests/mail_test_helpers";
import { describe, test } from "@odoo/hoot";
import { press } from "@odoo/hoot-dom";
import { mockDate } from "@odoo/hoot-mock";
import { inputFiles } from "@web/../tests/utils";
import {
asyncStep,
getService,
mockService,
serverState,
waitForSteps,
} from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
defineMailModels();
test("Messages are received cross-tab", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
const env1 = await start({ asTab: true });
const env2 = await start({ asTab: true });
await openDiscuss(channelId, { target: env1 });
await openDiscuss(channelId, { target: env2 });
await contains(`${env1.selector} .o-mail-Thread:contains('Welcome to #General!')`); // wait for loaded and focus in input
await contains(`${env2.selector} .o-mail-Thread:contains('Welcome to #General!')`); // wait for loaded and focus in input
await insertText(`${env1.selector} .o-mail-Composer-input`, "Hello World!");
await press("Enter");
await contains(`${env1.selector} .o-mail-Message-content`, { text: "Hello World!" });
await contains(`${env2.selector} .o-mail-Message-content`, { text: "Hello World!" });
});
test.tags("focus required");
test("Thread rename", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({
create_uid: serverState.userId,
name: "General",
});
const env1 = await start({ asTab: true });
const env2 = await start({ asTab: true });
await openDiscuss(channelId, { target: env1 });
await openDiscuss(channelId, { target: env2 });
await insertText(`${env1.selector} .o-mail-DiscussContent-threadName:enabled`, "Sales", {
replace: true,
});
triggerHotkey("Enter");
await contains(`${env2.selector} .o-mail-DiscussContent-threadName[title='Sales']`);
await contains(`${env2.selector} .o-mail-DiscussSidebarChannel`, { text: "Sales" });
});
test.tags("focus required");
test("Thread description update", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({
create_uid: serverState.userId,
name: "General",
});
const env1 = await start({ asTab: true });
const env2 = await start({ asTab: true });
await openDiscuss(channelId, { target: env1 });
await openDiscuss(channelId, { target: env2 });
await insertText(
`${env1.selector} .o-mail-DiscussContent-threadDescription`,
"The very best channel",
{
replace: true,
}
);
triggerHotkey("Enter");
await contains(
`${env2.selector} .o-mail-DiscussContent-threadDescription[title='The very best channel']`
);
});
test.skip("Channel subscription is renewed when channel is added from invite", async () => {
const now = luxon.DateTime.now();
mockDate(`${now.year}-${now.month}-${now.day} ${now.hour}:${now.minute}:${now.second}`);
const pyEnv = await startServer();
const [, channelId] = pyEnv["discuss.channel"].create([
{ name: "R&D" },
{ name: "Sales", channel_member_ids: [] },
]);
// Patch the date to consider those channels as already known by the server
// when the client starts.
const later = now.plus({ seconds: 10 });
mockDate(
`${later.year}-${later.month}-${later.day} ${later.hour}:${later.minute}:${later.second}`
);
await start();
mockService("bus_service", {
forceUpdateChannels() {
asyncStep("update-channels");
},
});
await openDiscuss();
await contains(".o-mail-DiscussSidebarChannel");
getService("orm").call("discuss.channel", "add_members", [[channelId]], {
partner_ids: [serverState.partnerId],
});
await contains(".o-mail-DiscussSidebarChannel", { count: 2 });
await waitForSteps(["update-channels"]); // FIXME: sometimes 1 or 2 update-channels
});
test("Adding attachments", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "Hogwarts Legacy" });
pyEnv["mail.message"].create({
body: "Hello world!",
model: "discuss.channel",
res_id: channelId,
message_type: "comment",
});
const env1 = await start({ asTab: true });
const env2 = await start({ asTab: true });
await openDiscuss(channelId, { target: env1 });
await openDiscuss(channelId, { target: env2 });
const file = new File(["file content"], "test.txt", { type: "text/plain" });
await contains(`${env1.selector} .o-mail-Message:contains('Hello world!')`);
await contains(`${env2.selector} .o-mail-Message:contains('Hello world!')`);
await click(`${env1.selector} .o-mail-Message button[title='Edit']`);
await click(`${env1.selector} .o-mail-Message .o-mail-Composer button[title='More Actions']`);
await click(`${env1.selector} .o_popover button[name='upload-files']`);
await inputFiles(`${env1.selector} .o-mail-Message .o-mail-Composer .o_input_file`, [file]);
await contains(
`${env1.selector} .o-mail-AttachmentContainer:not(.o-isUploading):contains(test.txt) .fa-check`
);
await click(`${env1.selector} .o-mail-Message .o-mail-Composer button[data-type='save']`);
await contains(
`${env2.selector} .o-mail-AttachmentContainer:not(.o-isUploading):contains(test.txt)`
);
});
test("Remove attachment from message", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
const attachmentId = pyEnv["ir.attachment"].create({
name: "test.txt",
mimetype: "text/plain",
});
pyEnv["mail.message"].create({
attachment_ids: [attachmentId],
body: "Hello World!",
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
});
const env1 = await start({ asTab: true });
const env2 = await start({ asTab: true });
await openDiscuss(channelId, { target: env1 });
await openDiscuss(channelId, { target: env2 });
await contains(`${env1.selector} .o-mail-AttachmentCard`, { text: "test.txt" });
await click(`${env2.selector} .o-mail-Attachment-unlink`);
await click(`${env2.selector} .modal-footer .btn`, { text: "Ok" });
await contains(`${env1.selector} .o-mail-AttachmentCard`, { count: 0, text: "test.txt" });
});
test("Message (hard) delete notification", async () => {
// Note: This isn't a notification from when user click on "Delete message" action:
// this happens when mail_message server record is effectively deleted (unlink)
const pyEnv = await startServer();
pyEnv["res.users"].write(serverState.userId, { notification_type: "inbox" });
const messageId = pyEnv["mail.message"].create({
body: "Needaction message",
model: "res.partner",
res_id: serverState.partnerId,
needaction: true,
});
pyEnv["mail.notification"].create({
mail_message_id: messageId,
notification_type: "inbox",
notification_status: "sent",
res_partner_id: serverState.partnerId,
});
await start();
await openDiscuss("mail.box_inbox");
await click("[title='Add Star']");
await contains("button", { text: "Inbox", contains: [".badge", { text: "1" }] });
await contains("button", { text: "Starred messages", contains: [".badge", { text: "1" }] });
const [partner] = pyEnv["res.partner"].read(serverState.partnerId);
pyEnv["bus.bus"]._sendone(partner, "mail.message/delete", {
message_ids: [messageId],
});
await contains(".o-mail-Message", { count: 0 });
await contains("button", { text: "Inbox", contains: [".badge", { count: 0 }] });
await contains("button", { text: "Starred messages", contains: [".badge", { count: 0 }] });
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,81 @@
import {
click,
contains,
start,
startServer,
openDiscuss,
mockGetMedia,
onlineTest,
defineMailModels,
} from "@mail/../tests/mail_test_helpers";
import { onRpc } from "@web/../tests/web_test_helpers";
import { PeerToPeer, UPDATE_EVENT } from "@mail/discuss/call/common/peer_to_peer";
defineMailModels();
function connectionReady(p2p) {
return new Promise((resolve) => {
p2p.addEventListener("update", ({ detail }) => {
if (
detail.name === UPDATE_EVENT.CONNECTION_CHANGE &&
detail.payload.state === "connected"
) {
resolve();
}
});
});
}
async function mockPeerToPeerCallEnvironment({ channelId, remoteSessionId }) {
const env = await start();
const rtc = env.services["discuss.rtc"];
const localUserP2P = env.services["discuss.p2p"];
const remoteUserP2P = new PeerToPeer({
notificationRoute: "/mail/rtc/session/notify_call_members",
});
remoteUserP2P.connect(remoteSessionId, channelId);
onRpc("/mail/rtc/session/notify_call_members", async (req) => {
const {
params: { peer_notifications },
} = await req.json();
for (const [sender, , message] of peer_notifications) {
/**
* This is a simplification, if more than 2 users we should check notification.target to know which user
* should get the notification.
*/
if (sender === rtc.selfSession.id) {
await remoteUserP2P.handleNotification(sender, message);
} else {
await localUserP2P.handleNotification(sender, message);
}
}
});
const localUserConnected = connectionReady(localUserP2P);
const remoteUserConnected = connectionReady(remoteUserP2P);
return { localUserConnected, remoteUserConnected };
}
onlineTest("Can join a call in p2p", async (assert) => {
mockGetMedia();
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
const remoteSessionId = pyEnv["discuss.channel.rtc.session"].create({
channel_member_id: pyEnv["discuss.channel.member"].create({
channel_id: channelId,
partner_id: pyEnv["res.partner"].create({ name: "Remote" }),
}),
channel_id: channelId,
});
const { localUserConnected, remoteUserConnected } = await mockPeerToPeerCallEnvironment({
channelId,
remoteSessionId,
});
await openDiscuss(channelId);
await click("[title='Join Call']");
await contains(".o-discuss-Call");
await contains(".o-discuss-CallParticipantCard[title='Remote']");
await Promise.all([localUserConnected, remoteUserConnected]);
await contains("span[data-connection-state='connected']");
});

View file

@ -0,0 +1,103 @@
import {
click,
contains,
defineMailModels,
mockGetMedia,
mockPermissionsPrompt,
openDiscuss,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { describe, test } from "@odoo/hoot";
describe.current.tags("desktop");
defineMailModels();
test("Starting a video call asks for permissions", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
mockGetMedia();
mockPermissionsPrompt();
const env = await start();
const rtc = env.services["discuss.rtc"];
await openDiscuss(channelId);
await click("[title='Start Video Call']");
await contains(".modal[role='dialog']", { count: 1 });
rtc.cameraPermission = "granted";
await click(".modal-footer button", { text: "Use Camera" });
await contains(".o-discuss-CallActionList button[title='Stop camera']");
});
test("Turning on the microphone asks for permissions", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
mockGetMedia();
mockPermissionsPrompt();
const env = await start();
const rtc = env.services["discuss.rtc"];
await openDiscuss(channelId);
await click("[title='Start Call']");
await contains(".o-discuss-CallActionList button[title='Turn camera on']");
await click(".o-discuss-CallActionList button[title='Unmute']");
await contains(".modal[role='dialog']", { count: 1 });
rtc.microphonePermission = "granted";
await click(".modal-footer button", { text: "Use Microphone" });
await contains(".o-discuss-CallActionList button[title='Mute']");
await contains(".o-discuss-CallActionList button[title='Turn camera on']");
});
test("Turning on the camera asks for permissions", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
mockGetMedia();
mockPermissionsPrompt();
const env = await start();
const rtc = env.services["discuss.rtc"];
await openDiscuss(channelId);
await click("[title='Start Call']");
await click(".o-discuss-CallActionList button[title='Turn camera on']");
await contains(".modal[role='dialog']", { count: 1 });
rtc.cameraPermission = "granted";
await click(".modal-footer button", { text: "Use Camera" });
await contains(".o-discuss-CallActionList button[title='Stop camera']");
});
test("Turn on both microphone and camera from permission dialog", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
mockGetMedia();
mockPermissionsPrompt();
const env = await start();
const rtc = env.services["discuss.rtc"];
await openDiscuss(channelId);
await click("[title='Start Call']");
await contains(".o-discuss-CallActionList button[title='Turn camera on']");
await click(".o-discuss-CallActionList button[title='Turn camera on']");
await contains(".modal[role='dialog']", { count: 1 });
rtc.microphonePermission = "granted";
rtc.cameraPermission = "granted";
await click(".modal-footer button", { text: "Use microphone and camera" });
await contains(".o-discuss-CallActionList button[title='Stop camera']");
await contains(".o-discuss-CallActionList button[title='Mute']");
});
test("Combined mic+camera button only shown when both permissions not granted", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
mockGetMedia();
mockPermissionsPrompt();
const env = await start();
const rtc = env.services["discuss.rtc"];
await openDiscuss(channelId);
await click("[title='Start Call']");
await click(".o-discuss-CallActionList button[title='Turn camera on']");
await contains(".modal-footer button", { count: 2 });
await contains(".modal-footer button", { text: "Use microphone and camera" });
await contains(".modal-footer button", { text: "Use Camera" });
rtc.cameraPermission = "granted";
await click(".modal-footer button", { text: "Use Camera" });
await click(".o-discuss-CallActionList button[title='Unmute']");
await contains(".modal-footer button");
await contains(".modal-footer button", { text: "Use Microphone" });
});

View file

@ -0,0 +1,22 @@
import {
click,
contains,
defineMailModels,
openDiscuss,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { describe, test } from "@odoo/hoot";
describe.current.tags("desktop");
defineMailModels();
test("Call has Picture-in-picture feature", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
await start();
await openDiscuss(channelId);
await click("[title='Start Call']");
await contains(".o-discuss-Call");
await contains(".o-discuss-Call-layoutActions button[title='Picture in Picture']");
});

View file

@ -0,0 +1,131 @@
import {
click,
contains,
defineMailModels,
editInput,
openDiscuss,
patchUiSize,
SIZES,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { describe, test, expect } from "@odoo/hoot";
import { advanceTime } from "@odoo/hoot-mock";
import { asyncStep, patchWithCleanup, waitForSteps } from "@web/../tests/web_test_helpers";
import { browser } from "@web/core/browser/browser";
describe.current.tags("desktop");
defineMailModels();
test("Renders the call settings", async () => {
patchWithCleanup(browser.navigator.mediaDevices, {
enumerateDevices: () =>
Promise.resolve([
{
deviceId: "mockAudioDeviceId",
kind: "audioinput",
label: "mockAudioDeviceLabel",
},
{
deviceId: "mockVideoDeviceId",
kind: "videoinput",
label: "mockVideoDeviceLabel",
},
]),
});
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "test" });
patchUiSize({ size: SIZES.SM });
const env = await start();
const rtc = env.services["discuss.rtc"];
await openDiscuss(channelId);
// dropdown requires an extra delay before click (because handler is registered in useEffect)
await contains("[title='Open Actions Menu']");
await click("[title='Open Actions Menu']");
await click(".o-dropdown-item", { text: "Call Settings" });
await contains(".o-discuss-CallSettings");
await contains("label[aria-label='Camera']");
await contains("label[aria-label='Microphone']");
await contains("label[aria-label='Audio Output']");
await contains("option", { textContent: "Permission Needed", count: 3 });
rtc.microphonePermission = "granted";
await contains("option[value=mockAudioDeviceId]");
rtc.cameraPermission = "granted";
await contains("option[value=mockVideoDeviceId]");
await contains("button", { text: "Voice Detection" });
await contains("button", { text: "Push to Talk" });
await contains("span", { text: "Voice detection sensitivity" });
await contains("button", { text: "Test" });
await contains("label", { text: "Show video participants only" });
await contains("label", { text: "Blur video background" });
});
test("activate push to talk", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "test" });
patchUiSize({ size: SIZES.SM });
await start();
await openDiscuss(channelId);
// dropdown requires an extra delay before click (because handler is registered in useEffect)
await contains("[title='Open Actions Menu']");
await click("[title='Open Actions Menu']");
await click(".o-dropdown-item", { text: "Call Settings" });
await click("button", { text: "Push to Talk" });
await contains("i[aria-label='Register new key']");
await contains("label", { text: "Delay after releasing push-to-talk" });
await contains("label", { text: "Voice detection sensitivity", count: 0 });
});
test("activate blur", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "test" });
patchUiSize({ size: SIZES.SM });
await start();
await openDiscuss(channelId);
// dropdown requires an extra delay before click (because handler is registered in useEffect)
await contains("[title='Open Actions Menu']");
await click("[title='Open Actions Menu']");
await click(".o-dropdown-item", { text: "Call Settings" });
await click("input[title='Blur video background']");
await contains("label", { text: "Blur video background" });
await contains("label", { text: "Edge blur intensity" });
});
test("local storage for call settings", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "test" });
localStorage.setItem("mail_user_setting_background_blur_amount", "3");
localStorage.setItem("mail_user_setting_edge_blur_amount", "5");
localStorage.setItem("mail_user_setting_show_only_video", "true");
localStorage.setItem("mail_user_setting_use_blur", "true");
patchWithCleanup(localStorage, {
setItem(key, value) {
if (key.startsWith("mail_user_setting")) {
asyncStep(`${key}: ${value}`);
}
return super.setItem(key, value);
},
});
patchUiSize({ size: SIZES.SM });
await start();
await openDiscuss(channelId);
// testing load from local storage
// dropdown requires an extra delay before click (because handler is registered in useEffect)
await contains("[title='Open Actions Menu']");
await click("[title='Open Actions Menu']");
await click(".o-dropdown-item", { text: "Call Settings" });
await contains("input[title='Show video participants only']:checked");
await contains("input[title='Blur video background']:checked");
await contains("label[title='Background blur intensity']", { text: "15%" });
await contains("label[title='Edge blur intensity']", { text: "25%" });
// testing save to local storage
await click("input[title='Show video participants only']");
await waitForSteps(["mail_user_setting_show_only_video: false"]);
await click("input[title='Blur video background']");
expect(localStorage.getItem("mail_user_setting_use_blur")).toBe(null);
await editInput(document.body, ".o-Discuss-CallSettings-thresholdInput", 0.3);
await advanceTime(2000); // threshold setting debounce timer
await waitForSteps(["mail_user_setting_voice_threshold: 0.3"]);
});

View file

@ -0,0 +1,211 @@
import { describe, expect } from "@odoo/hoot";
import { advanceTime } from "@odoo/hoot-mock";
import { browser } from "@web/core/browser/browser";
import { asyncStep, onRpc, mountWebClient, waitForSteps } from "@web/../tests/web_test_helpers";
import { defineMailModels, mockGetMedia, onlineTest } from "@mail/../tests/mail_test_helpers";
import { PeerToPeer, STREAM_TYPE, UPDATE_EVENT } from "@mail/discuss/call/common/peer_to_peer";
describe.current.tags("desktop");
defineMailModels();
class Network {
_peerToPeerInstances = new Map();
_notificationRoute;
constructor(route) {
this._notificationRoute = route || "/any/mock/notification";
onRpc(this._notificationRoute, async (req) => {
const {
params: { peer_notifications },
} = await req.json();
for (const notification of peer_notifications) {
const [sender_session_id, target_session_ids, content] = notification;
for (const id of target_session_ids) {
const p2p = this._peerToPeerInstances.get(id);
p2p.handleNotification(sender_session_id, content);
}
}
});
}
/**
* @param id
* @return {{id, p2p: PeerToPeer}}
*/
register(id) {
const p2p = new PeerToPeer({ notificationRoute: this._notificationRoute });
this._peerToPeerInstances.set(id, p2p);
return { id, p2p };
}
close() {
for (const p2p of this._peerToPeerInstances.values()) {
p2p.disconnect();
}
}
}
onlineTest("basic peer to peer connection", async () => {
await mountWebClient();
const channelId = 1;
const network = new Network();
const user1 = network.register(1);
const user2 = network.register(2);
user2.p2p.addEventListener("update", ({ detail: { name, payload } }) => {
if (name === UPDATE_EVENT.CONNECTION_CHANGE && payload.state === "connected") {
asyncStep(payload.state);
}
});
user2.p2p.connect(user2.id, channelId);
user1.p2p.connect(user1.id, channelId);
await user1.p2p.addPeer(user2.id);
await waitForSteps(["connected"]);
network.close();
});
onlineTest("mesh peer to peer connections", async () => {
await mountWebClient();
const channelId = 2;
const network = new Network();
const userCount = 10;
const users = Array.from({ length: userCount }, (_, i) => network.register(i));
const promises = [];
for (const user of users) {
user.p2p.connect(user.id, channelId);
for (let i = 0; i < user.id; i++) {
promises.push(user.p2p.addPeer(i));
}
}
await Promise.all(promises);
let connectionsCount = 0;
for (const user of users) {
connectionsCount += user.p2p.peers.size;
}
expect(connectionsCount).toBe(userCount * (userCount - 1));
connectionsCount = 0;
network.close();
for (const user of users) {
connectionsCount += user.p2p.peers.size;
}
expect(connectionsCount).toBe(0);
});
onlineTest("connection recovery", async () => {
await mountWebClient();
const channelId = 1;
const network = new Network();
const user1 = network.register(1);
const user2 = network.register(2);
user2.remoteStates = new Map();
user2.p2p.addEventListener("update", ({ detail: { name, payload } }) => {
if (name === UPDATE_EVENT.CONNECTION_CHANGE && payload.state === "connected") {
asyncStep(payload.state);
}
});
user1.p2p.connect(user1.id, channelId);
user1.p2p.addPeer(user2.id);
// only connecting user2 after user1 has called addPeer so that user2 ignores notifications
// from user1, which simulates a connection drop that should be recovered.
user2.p2p.connect(user2.id, channelId);
const openPromise = new Promise((resolve) => {
user1.p2p.peers.get(2).dataChannel.onopen = resolve;
});
advanceTime(5_000); // recovery timeout
await openPromise;
await waitForSteps(["connected"]);
network.close();
});
onlineTest("can broadcast a stream and control download", async () => {
mockGetMedia();
await mountWebClient();
const channelId = 3;
const network = new Network();
const user1 = network.register(1);
const user2 = network.register(2);
user2.remoteMedia = new Map();
const trackPromise = new Promise((resolve) => {
user2.p2p.addEventListener("update", ({ detail: { name, payload } }) => {
if (name === UPDATE_EVENT.TRACK) {
user2.remoteMedia.set(payload.sessionId, {
[payload.type]: {
track: payload.track,
active: payload.active,
},
});
resolve();
}
});
});
user2.p2p.connect(user2.id, channelId);
user1.p2p.connect(user1.id, channelId);
await user1.p2p.addPeer(user2.id);
const videoStream = await browser.navigator.mediaDevices.getUserMedia({
video: true,
});
const videoTrack = videoStream.getVideoTracks()[0];
await user1.p2p.updateUpload(STREAM_TYPE.CAMERA, videoTrack);
await trackPromise;
const user2RemoteMedia = user2.remoteMedia.get(user1.id);
const user2CameraTransceiver = user2.p2p.peers.get(user1.id).getTransceiver(STREAM_TYPE.CAMERA);
expect(user2CameraTransceiver.direction).toBe("recvonly");
expect(user2RemoteMedia[STREAM_TYPE.CAMERA].track.kind).toBe("video");
expect(user2RemoteMedia[STREAM_TYPE.CAMERA].active).toBe(true);
user2.p2p.updateDownload(user1.id, { camera: false });
expect(user2CameraTransceiver.direction).toBe("inactive");
network.close();
});
onlineTest("can broadcast arbitrary messages (dataChannel)", async () => {
await mountWebClient();
const channelId = 4;
const network = new Network();
const user1 = network.register(1);
const user2 = network.register(2);
user2.p2p.connect(user2.id, channelId);
user1.p2p.connect(user1.id, channelId);
await user1.p2p.addPeer(user2.id);
user1.inbox = [];
const pongPromise = new Promise((resolve) => {
user1.p2p.addEventListener("update", ({ detail: { name, payload } }) => {
if (name === UPDATE_EVENT.BROADCAST) {
user1.inbox.push(payload);
resolve();
}
});
});
user2.inbox = [];
user2.p2p.addEventListener("update", ({ detail: { name, payload } }) => {
if (name === UPDATE_EVENT.BROADCAST && payload.message === "ping") {
user2.inbox.push(payload);
user2.p2p.broadcast("pong");
}
});
user1.p2p.broadcast("ping");
await pongPromise;
expect(user2.inbox[0].senderId).toBe(user1.id);
expect(user2.inbox[0].message).toBe("ping");
expect(user1.inbox[0].senderId).toBe(user2.id);
expect(user1.inbox[0].message).toBe("pong");
network.close();
});
onlineTest("can reject arbitrary offers", async () => {
await mountWebClient();
const channelId = 1;
const network = new Network();
const user1 = network.register(1);
const user2 = network.register(2);
user2.p2p.connect(user2.id, channelId);
user1.p2p.connect(user1.id, channelId);
user2.p2p._emitLog = (id, message) => {
if (message === "offer rejected") {
asyncStep("offer rejected");
}
};
user2.p2p.acceptOffer = (id, sequence) => id !== user1.id || sequence > 20;
user1.p2p.addPeer(user2.id, { sequence: 19 });
await waitForSteps(["offer rejected"]);
network.close();
});

View file

@ -0,0 +1,47 @@
import {
click,
contains,
defineMailModels,
mockGetMedia,
openDiscuss,
patchUiSize,
SIZES,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { pttExtensionServiceInternal } from "@mail/discuss/call/common/ptt_extension_service";
import { describe, test } from "@odoo/hoot";
import { patchWithCleanup } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
defineMailModels();
test("display banner when ptt extension is not enabled", async () => {
mockGetMedia();
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
patchWithCleanup(pttExtensionServiceInternal, {
onAnswerIsEnabled(pttService) {
pttService.isEnabled = false;
},
});
patchUiSize({ size: SIZES.SM });
await start();
await openDiscuss(channelId);
// dropdown requires an extra delay before click (because handler is registered in useEffect)
await contains("[title='Open Actions Menu']");
await click("[title='Open Actions Menu']");
await click(".o-dropdown-item", { text: "Call Settings" });
await click("button", { text: "Push to Talk" });
await click("[title*='Close Chat Window']");
await click("button[title='New Meeting']");
await click("button[title='Close panel']"); // invitation panel automatically open
await contains(".o-discuss-PttAdBanner");
// dropdown requires an extra delay before click (because handler is registered in useEffect)
await contains("[title='Open Actions Menu']");
await click("[title='Open Actions Menu']");
await click(".o-dropdown-item", { text: "Call Settings" });
await click("button", { text: "Voice Detection" });
await click("[title*='Close Chat Window']");
await contains(".o-discuss-PttAdBanner", { count: 0 });
});

View file

@ -0,0 +1,123 @@
import {
click,
contains,
defineMailModels,
insertText,
mockGetMedia,
openDiscuss,
patchUiSize,
SIZES,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { pttExtensionServiceInternal } from "@mail/discuss/call/common/ptt_extension_service";
import { PTT_RELEASE_DURATION } from "@mail/discuss/call/common/rtc_service";
import { advanceTime, freezeTime, keyDown, mockTouch, mockUserAgent, test } from "@odoo/hoot";
import { patchWithCleanup, serverState } from "@web/../tests/web_test_helpers";
import { browser } from "@web/core/browser/browser";
defineMailModels();
test.tags("desktop");
test("no auto-call on joining chat", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Mario" });
pyEnv["res.users"].create({ partner_id: partnerId });
await start();
await openDiscuss();
await click("input[placeholder='Search conversations']");
await contains(".o_command_name", { count: 5 });
await insertText("input[placeholder='Search a conversation']", "mario");
await contains(".o_command_name", { count: 3 });
await click(".o_command_name", { text: "Mario" });
await contains(".o-mail-DiscussSidebar-item", { text: "Mario" });
await contains(".o-mail-Message", { count: 0 });
await contains(".o-discuss-Call", { count: 0 });
});
test.tags("desktop");
test("no auto-call on joining group chat", async () => {
const pyEnv = await startServer();
const [partnerId_1, partnerId_2] = pyEnv["res.partner"].create([
{ name: "Mario" },
{ name: "Luigi" },
]);
pyEnv["res.users"].create([{ partner_id: partnerId_1 }, { partner_id: partnerId_2 }]);
await start();
await openDiscuss();
await click("input[placeholder='Search conversations']");
await click("a", { text: "Create Chat" });
await click("li", { text: "Mario" });
await click("li", { text: "Luigi" });
await click("button", { text: "Create Group Chat" });
await contains(".o-mail-DiscussSidebar-item:contains('Mario, and Luigi')");
await contains(".o-mail-Message", { count: 0 });
await contains(".o-discuss-Call", { count: 0 });
});
test.tags("mobile");
test("show Push-to-Talk button on mobile", async () => {
mockGetMedia();
mockTouch(true);
mockUserAgent("android");
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
patchWithCleanup(pttExtensionServiceInternal, {
onAnswerIsEnabled(pttService) {
pttService.isEnabled = false;
},
});
patchUiSize({ size: SIZES.SM });
await start();
await openDiscuss(channelId);
await click(".o-mail-ChatWindow-moreActions", { text: "General" });
await click(".o-dropdown-item:text('Start Call')");
// dropdown requires an extra delay before click (because handler is registered in useEffect)
await contains("[title='Open Actions Menu']");
await click("[title='Open Actions Menu']");
await click(".o-dropdown-item", { text: "Call Settings" });
await click("button", { text: "Push to Talk" });
// dropdown requires an extra delay before click (because handler is registered in useEffect)
await contains("[title='Open Actions Menu']");
await click("[title='Open Actions Menu']");
await click(".o-dropdown-item", { text: "Call Settings" });
await contains("button", { text: "Push to talk" });
});
test.tags("desktop");
test("Can push-to-talk", async () => {
mockGetMedia();
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
pyEnv["res.users.settings"].create({
use_push_to_talk: true,
user_id: serverState.userId,
push_to_talk_key: "...f",
});
patchWithCleanup(pttExtensionServiceInternal, {
onAnswerIsEnabled(pttService) {
pttService.isEnabled = false;
},
});
freezeTime();
await start();
await openDiscuss(channelId);
await advanceTime(1000);
await click("[title='Start Call']");
await advanceTime(1000);
await contains(".o-discuss-Call");
await click(".o-discuss-Call");
await advanceTime(1000);
await keyDown("f");
await advanceTime(PTT_RELEASE_DURATION);
await contains(".o-discuss-CallParticipantCard .o-isTalking");
// switching tab while PTT key still pressed then released on other tab should eventually release PTT
browser.dispatchEvent(new Event("blur"));
await advanceTime(PTT_RELEASE_DURATION + 1000);
await contains(".o-discuss-CallParticipantCard:not(:has(.o-isTalking))");
await click(".o-discuss-Call");
await advanceTime(1000);
await keyDown("f");
await advanceTime(PTT_RELEASE_DURATION);
await contains(".o-discuss-CallParticipantCard .o-isTalking");
});

View file

@ -0,0 +1,56 @@
import {
click,
contains,
defineMailModels,
openDiscuss,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { describe, test } from "@odoo/hoot";
describe.current.tags("desktop");
defineMailModels();
test("Empty attachment panel", async () => {
const pyEnv = await startServer();
const channelId = await pyEnv["discuss.channel"].create({ name: "General" });
await start();
await openDiscuss(channelId);
await contains(".o-discuss-ChannelMemberList"); // wait for auto-open of this panel
await click(".o-mail-DiscussContent-header button[title='Attachments']");
await contains(".o-mail-ActionPanel", {
text: "This channel doesn't have any attachments.",
});
});
test("Attachment panel sort by date", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
pyEnv["ir.attachment"].create([
{
res_id: channelId,
res_model: "discuss.channel",
name: "file1.pdf",
create_date: "2023-08-20 10:00:00",
},
{
res_id: channelId,
res_model: "discuss.channel",
name: "file2.pdf",
create_date: "2023-09-21 10:00:00",
},
]);
await start();
await openDiscuss(channelId);
await contains(".o-discuss-ChannelMemberList"); // wait for auto-open of this panel
await click(".o-mail-DiscussContent-header button[title='Attachments']");
await contains(".o-mail-AttachmentList", {
text: "file2.pdf",
after: [".o-mail-DateSection", { text: "September, 2023" }],
before: [".o-mail-DateSection", { text: "August, 2023" }],
});
await contains(".o-mail-AttachmentList", {
text: "file1.pdf",
after: [".o-mail-DateSection", { text: "August, 2023" }],
});
});

View file

@ -0,0 +1,112 @@
import { waitForChannels } from "@bus/../tests/bus_test_helpers";
import { onWebsocketEvent } from "@bus/../tests/mock_websocket";
import {
click,
contains,
defineMailModels,
insertText,
openDiscuss,
setupChatHub,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { describe, edit, expect, mockDate, press, test } from "@odoo/hoot";
import { Command } from "@web/../tests/web_test_helpers";
defineMailModels();
describe.current.tags("desktop");
test("bus subscription updated when joining/leaving thread as non member", async () => {
const pyEnv = await startServer();
const johnUser = pyEnv["res.users"].create({ name: "John" });
const johnPartner = pyEnv["res.partner"].create({ name: "John", user_ids: [johnUser] });
const channelId = pyEnv["discuss.channel"].create({
channel_member_ids: [Command.create({ partner_id: johnPartner })],
name: "General",
});
await start();
await openDiscuss(channelId);
await waitForChannels([`discuss.channel_${channelId}`]);
await click("[title='Channel Actions']");
await click(".o-dropdown-item:contains('Leave Channel')");
await click("button", { text: "Leave Conversation" });
await waitForChannels([`discuss.channel_${channelId}`], { operation: "delete" });
});
test("bus subscription updated when opening/closing chat window as a non member", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({
channel_member_ids: [],
name: "Sales",
});
setupChatHub({ opened: [channelId] });
await start();
await contains(".o-mail-ChatWindow", { text: "Sales" });
await waitForChannels([`discuss.channel_${channelId}`]);
await click("[title*='Close Chat Window']", {
parent: [".o-mail-ChatWindow", { text: "Sales" }],
});
await contains(".o-mail-ChatWindow", { count: 0, text: "Sales" });
await waitForChannels([`discuss.channel_${channelId}`], { operation: "delete" });
await press(["control", "k"]);
await click(".o_command_palette_search input");
await edit("@");
await click(".o-mail-DiscussCommand", { text: "Sales" });
await waitForChannels([`discuss.channel_${channelId}`]);
});
test("bus subscription updated when joining locally pinned thread", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({
channel_member_ids: [],
name: "General",
});
await start();
await openDiscuss(channelId);
await waitForChannels([`discuss.channel_${channelId}`]);
await contains(".o-discuss-ChannelMemberList"); // wait for auto-open of this panel
await click("[title='Invite People']");
await click(".o-discuss-ChannelInvitation-selectable", {
text: "Mitchell Admin",
});
await click(".o-discuss-ChannelInvitation [title='Invite']:enabled");
await waitForChannels([`discuss.channel_${channelId}`], { operation: "delete" });
});
test("bus subscription is refreshed when channel is joined", async () => {
const pyEnv = await startServer();
pyEnv["discuss.channel"].create([{ name: "General" }, { name: "Sales" }]);
onWebsocketEvent("subscribe", () => expect.step("subscribe"));
const later = luxon.DateTime.now().plus({ seconds: 2 });
mockDate(
`${later.year}-${later.month}-${later.day} ${later.hour}:${later.minute}:${later.second}`
);
await start();
await expect.waitForSteps(["subscribe"]);
await openDiscuss();
await expect.waitForSteps([]);
await click("input[placeholder='Search conversations']");
await insertText("input[placeholder='Search a conversation']", "new channel");
await expect.waitForSteps(["subscribe"]);
});
test("bus subscription is refreshed when channel is left", async () => {
const pyEnv = await startServer();
pyEnv["discuss.channel"].create({ name: "General" });
onWebsocketEvent("subscribe", () => expect.step("subscribe"));
const later = luxon.DateTime.now().plus({ seconds: 2 });
mockDate(
`${later.year}-${later.month}-${later.day} ${later.hour}:${later.minute}:${later.second}`
);
await start();
await expect.waitForSteps(["subscribe"]);
await openDiscuss();
await expect.waitForSteps([]);
await click("[title='Channel Actions']");
await click(".o-dropdown-item:contains('Leave Channel')");
await expect.waitForSteps(["subscribe"]);
});

View file

@ -0,0 +1,260 @@
import {
click,
contains,
defineMailModels,
insertText,
mockPermissionsPrompt,
openDiscuss,
setupChatHub,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { describe, test } from "@odoo/hoot";
import { animationFrame } from "@odoo/hoot-dom";
import { mockDate } from "@odoo/hoot-mock";
import { Command, getService, serverState, withUser } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
defineMailModels();
test("should display the channel invitation form after clicking on the invite button of a chat", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({
email: "testpartner@odoo.com",
name: "TestPartner",
});
pyEnv["res.users"].create({ partner_id: partnerId });
const channelId = pyEnv["discuss.channel"].create({
name: "TestChannel",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
channel_type: "channel",
});
await start();
await openDiscuss(channelId);
await contains(".o-discuss-ChannelMemberList"); // wait for auto-open of this panel
await click(".o-mail-DiscussContent-header button[title='Invite People']");
await contains(".o-discuss-ChannelInvitation");
});
test("can invite users in channel from chat window", async () => {
mockDate("2025-01-01 12:00:00", +1);
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({
email: "testpartner@odoo.com",
name: "TestPartner",
});
pyEnv["res.users"].create({ partner_id: partnerId });
const channelId = pyEnv["discuss.channel"].create({
name: "TestChannel",
channel_type: "channel",
});
setupChatHub({ opened: [channelId] });
await start();
// dropdown requires an extra delay before click (because handler is registered in useEffect)
await contains("[title='Open Actions Menu']");
await click("[title='Open Actions Menu']");
await click(".o-dropdown-item", { text: "Invite People" });
await contains(".o-discuss-ChannelInvitation");
await click(".o-discuss-ChannelInvitation-selectable", { text: "TestPartner" });
await click(".o-discuss-ChannelInvitation [title='Invite']:enabled");
await contains(".o-discuss-ChannelInvitation", { count: 0 });
await contains(".o-mail-Thread .o-mail-NotificationMessage", {
text: "Mitchell Admin invited TestPartner to the channel1:00 PM",
});
});
test("should be able to search for a new user to invite from an existing chat", async () => {
const pyEnv = await startServer();
const partnerId_1 = pyEnv["res.partner"].create({
email: "testpartner@odoo.com",
name: "TestPartner",
});
const partnerId_2 = pyEnv["res.partner"].create({
email: "testpartner2@odoo.com",
name: "TestPartner2",
});
pyEnv["res.users"].create({ partner_id: partnerId_1 });
pyEnv["res.users"].create({ partner_id: partnerId_2 });
const channelId = pyEnv["discuss.channel"].create({
name: "TestChannel",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId_1 }),
],
channel_type: "channel",
});
await start();
await openDiscuss(channelId);
await contains(".o-discuss-ChannelMemberList"); // wait for auto-open of this panel
await click(".o-mail-DiscussContent-header button[title='Invite People']");
await insertText(".o-discuss-ChannelInvitation-search", "TestPartner2");
await contains(".o-discuss-ChannelInvitation-selectable", { text: "TestPartner2" });
});
test("Invitation form should display channel group restriction", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({
email: "testpartner@odoo.com",
name: "TestPartner",
});
pyEnv["res.users"].create({ partner_id: partnerId });
const groupId = pyEnv["res.groups"].create({
name: "testGroup",
});
const channelId = pyEnv["discuss.channel"].create({
name: "TestChannel",
channel_type: "channel",
group_public_id: groupId,
});
await start();
await openDiscuss(channelId);
await contains(".o-discuss-ChannelMemberList"); // wait for auto-open of this panel
await click(".o-mail-DiscussContent-header button[title='Invite People']");
await contains(".o-discuss-ChannelInvitation div", {
text: 'Access restricted to group "testGroup"',
after: ["button .fa.fa-copy"],
});
});
test("should be able to create a new group chat from an existing chat", async () => {
const pyEnv = await startServer();
const partnerId_1 = pyEnv["res.partner"].create({
email: "testpartner@odoo.com",
name: "TestPartner",
});
const partnerId_2 = pyEnv["res.partner"].create({
email: "testpartner2@odoo.com",
name: "TestPartner2",
});
pyEnv["res.users"].create({ partner_id: partnerId_1 });
pyEnv["res.users"].create({ partner_id: partnerId_2 });
const channelId = pyEnv["discuss.channel"].create({
name: "TestChannel",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId_1 }),
],
channel_type: "chat",
});
await start();
await openDiscuss(channelId);
await click(".o-mail-DiscussContent-header button[title='Invite People']");
await contains(".o-discuss-ChannelInvitation");
await insertText(".o-discuss-ChannelInvitation-search", "TestPartner2");
await click(".o-discuss-ChannelInvitation-selectable", { text: "TestPartner2" });
await click("button[title='Create Group Chat']:enabled");
await contains(".o-discuss-ChannelInvitation", { count: 0 });
await contains(".o-mail-DiscussSidebarChannel", {
text: "Mitchell Admin, TestPartner, and TestPartner2",
});
});
test("unnamed group chat should display correct name just after being invited", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({
email: "jane@example.com",
name: "Jane",
});
const userId = pyEnv["res.users"].create({ partner_id: partnerId });
const [, channelId] = pyEnv["discuss.channel"].create([
{ name: "General" },
{
channel_member_ids: [Command.create({ partner_id: partnerId })],
channel_type: "group",
},
]);
await start();
await openDiscuss();
await contains(".o-mail-DiscussSidebarChannel", { text: "General" });
await contains(".o-mail-DiscussSidebarChannel", { count: 0, text: "Jane and Mitchell Admin" });
const currentPartnerId = serverState.partnerId;
await withUser(userId, async () => {
await getService("orm").call("discuss.channel", "add_members", [[channelId]], {
partner_ids: [currentPartnerId],
});
});
await contains(".o-mail-DiscussSidebarChannel", { text: "Jane and Mitchell Admin" });
await contains(".o_notification", {
text: "You have been invited to #Jane and Mitchell Admin",
});
});
test("invite user to self chat opens DM chat with user", async () => {
const pyEnv = await startServer();
const guestId = pyEnv["mail.guest"].create({ name: "TestGuest" });
const partnerId_1 = pyEnv["res.partner"].create({
email: "testpartner@odoo.com",
name: "TestPartner",
});
pyEnv["res.users"].create({ partner_id: partnerId_1 });
const [selfChatId] = pyEnv["discuss.channel"].create([
{
channel_member_ids: [Command.create({ partner_id: serverState.partnerId })],
channel_type: "chat",
},
{
channel_member_ids: [
Command.create({ partner_id: partnerId_1 }),
Command.create({ partner_id: serverState.partnerId }),
],
channel_type: "group",
},
{
// group chat with guest as correspondent for coverage of no crash
channel_member_ids: [
Command.create({ guest_id: guestId }),
Command.create({ partner_id: serverState.partnerId }),
],
channel_type: "group",
},
{
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId_1 }),
],
channel_type: "chat",
},
]);
await start();
await openDiscuss(selfChatId);
await contains(".o-mail-DiscussSidebarChannel", { text: "Mitchell Admin" }); // self-chat
await contains(".o-mail-DiscussSidebarChannel", { text: "TestPartner and Mitchell Admin" });
await contains(".o-mail-DiscussSidebarChannel", { text: "TestGuest and Mitchell Admin" });
await contains(".o-mail-DiscussSidebarChannel", { text: "TestPartner" });
await click(".o-mail-DiscussContent-header button[title='Invite People']");
await insertText(".o-discuss-ChannelInvitation-search", "TestPartner");
await click(".o-discuss-ChannelInvitation-selectable", { text: "TestPartner" });
await click("button:contains('Go to Conversation'):enabled");
await contains(".o-mail-DiscussSidebarChannel.o-active", { text: "TestPartner" });
});
test("Invite sidebar action has the correct title for group chats", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Demo" });
const channelId = pyEnv["discuss.channel"].create({
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
channel_type: "group",
});
await start();
await openDiscuss(channelId);
await click("button[title='Chat Actions']");
await click(".o-dropdown-item", { text: "Invite People" });
await contains(".modal-title", { text: "Mitchell Admin and Demo" });
});
test("Active dialog retains focus over invite input", async () => {
await startServer();
mockPermissionsPrompt();
await start();
await openDiscuss();
await click("button[title='New Meeting']");
await animationFrame();
await contains(".o-discuss-ChannelInvitation");
await contains("button:focus", { text: "Use Camera" });
});

View file

@ -0,0 +1,233 @@
import {
click,
contains,
defineMailModels,
openDiscuss,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { describe, test } from "@odoo/hoot";
import { Command, getService, serverState, withUser } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
defineMailModels();
test("there should be a button to show member list in the thread view topbar initially", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Demo" });
const channelId = pyEnv["discuss.channel"].create({
name: "TestChannel",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
channel_type: "channel",
});
await start();
await openDiscuss(channelId);
await contains("[title='Members']");
});
test("should show member list when clicking on member list button in thread view topbar", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Demo" });
const channelId = pyEnv["discuss.channel"].create({
name: "TestChannel",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
channel_type: "channel",
});
await start();
await openDiscuss(channelId);
await contains(".o-discuss-ChannelMemberList"); // open by default
await click("[title='Members']");
await contains(".o-discuss-ChannelMemberList", { count: 0 });
await click("[title='Members']");
await contains(".o-discuss-ChannelMemberList");
});
test("should have correct members in member list", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Demo" });
const channelId = pyEnv["discuss.channel"].create({
name: "TestChannel",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
channel_type: "channel",
});
await start();
await openDiscuss(channelId);
await contains(".o-discuss-ChannelMember", { count: 2 });
await contains(".o-discuss-ChannelMember", { text: serverState.partnerName });
await contains(".o-discuss-ChannelMember", { text: "Demo" });
});
test("members should be correctly categorised into online/offline", async () => {
const pyEnv = await startServer();
const [onlinePartnerId, idlePartnerId] = pyEnv["res.partner"].create([
{ name: "Online Partner", im_status: "online" },
{ name: "Idle Partner", im_status: "away" },
]);
pyEnv["res.partner"].write([serverState.partnerId], { im_status: "im_partner" });
const channelId = pyEnv["discuss.channel"].create({
name: "TestChanel",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: onlinePartnerId }),
Command.create({ partner_id: idlePartnerId }),
],
channel_type: "channel",
});
await start();
await openDiscuss(channelId);
await contains(".o-discuss-ChannelMemberList h6", { text: "Online - 2" });
await contains(".o-discuss-ChannelMemberList h6", { text: "Offline - 1" });
});
test("chat with member should be opened after clicking on channel member", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Demo" });
pyEnv["res.users"].create({ partner_id: partnerId });
const channelId = pyEnv["discuss.channel"].create({
name: "TestChannel",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
channel_type: "channel",
});
await start();
await openDiscuss(channelId);
await click(".o-discuss-ChannelMember.cursor-pointer", { text: "Demo" });
await contains(".o_avatar_card .o_card_user_infos", { text: "Demo" });
await click(".o_avatar_card button", { text: "Send message" });
await contains(".o-mail-AutoresizeInput[title='Demo']");
});
test("should show a button to load more members if they are not all loaded", async () => {
// Test assumes at most 100 members are loaded at once.
const pyEnv = await startServer();
const channel_member_ids = [];
for (let i = 0; i < 101; i++) {
const partnerId = pyEnv["res.partner"].create({ name: "name" + i });
channel_member_ids.push(Command.create({ partner_id: partnerId }));
}
const channelId = pyEnv["discuss.channel"].create({
name: "TestChannel",
channel_type: "channel",
});
await start();
await openDiscuss(channelId);
pyEnv["discuss.channel"].write([channelId], { channel_member_ids });
await contains(
".o-mail-ActionPanel:has(.o-mail-ActionPanel-header:contains('Members')) button",
{ text: "Load more" }
);
});
test("Load more button should load more members", async () => {
// Test assumes at most 100 members are loaded at once.
const pyEnv = await startServer();
const channel_member_ids = [];
for (let i = 0; i < 101; i++) {
const partnerId = pyEnv["res.partner"].create({ name: "name" + i });
channel_member_ids.push(Command.create({ partner_id: partnerId }));
}
const channelId = pyEnv["discuss.channel"].create({
name: "TestChannel",
channel_type: "channel",
});
await start();
await openDiscuss(channelId);
pyEnv["discuss.channel"].write([channelId], { channel_member_ids });
await click(
".o-mail-ActionPanel:has(.o-mail-ActionPanel-header:contains('Members')) [title='Load more']"
);
await contains(".o-discuss-ChannelMember", { count: 102 });
});
test("Channel member count update after user joined", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
const userId = pyEnv["res.users"].create({ name: "Harry" });
pyEnv["res.partner"].create({ name: "Harry", user_ids: [userId] });
await start();
await openDiscuss(channelId);
await contains(".o-discuss-ChannelMemberList"); // wait for auto-open of this panel
await contains(".o-discuss-ChannelMemberList h6", { text: "Offline - 1" });
await click("[title='Invite People']");
await click(".o-discuss-ChannelInvitation-selectable", { text: "Harry" });
await click(".o-discuss-ChannelInvitation [title='Invite']:enabled");
await contains(".o-discuss-ChannelInvitation", { count: 0 });
await click("[title='Members']");
await contains(".o-discuss-ChannelMemberList h6", { text: "Offline - 2" });
});
test("Channel member count update after user left", async () => {
const pyEnv = await startServer();
const userId = pyEnv["res.users"].create({ name: "Dobby" });
const partnerId = pyEnv["res.partner"].create({ name: "Dobby", user_ids: [userId] });
const channelId = pyEnv["discuss.channel"].create({
name: "General",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
});
await start();
await openDiscuss(channelId);
await contains(".o-discuss-ChannelMember", { count: 2 });
await withUser(userId, () =>
getService("orm").call("discuss.channel", "action_unfollow", [channelId])
);
await contains(".o-discuss-ChannelMember", { count: 1 });
});
test("Members are partitioned by online/offline", async () => {
const pyEnv = await startServer();
const [userId_1, userId_2] = pyEnv["res.users"].create([{ name: "Dobby" }, { name: "John" }]);
const [partnerId_1, partnerId_2] = pyEnv["res.partner"].create([
{
name: "Dobby",
user_ids: [userId_1],
im_status: "offline",
},
{
name: "John",
user_ids: [userId_2],
im_status: "online",
},
]);
const channelId = pyEnv["discuss.channel"].create({
name: "General",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId_1 }),
Command.create({ partner_id: partnerId_2 }),
],
});
pyEnv["res.partner"].write([serverState.partnerId], { im_status: "online" });
await start();
await openDiscuss(channelId);
await contains(".o-discuss-ChannelMember", { count: 3 });
await contains("h6", { text: "Online - 2" });
await contains("h6", { text: "Offline - 1" });
await contains(".o-discuss-ChannelMember", {
text: "John",
after: ["h6", { text: "Online - 2" }],
before: ["h6", { text: "Offline - 1" }],
});
await contains(".o-discuss-ChannelMember", {
text: "Mitchell Admin",
after: ["h6", { text: "Online - 2" }],
before: ["h6", { text: "Offline - 1" }],
});
await contains(".o-discuss-ChannelMember", {
text: "Dobby",
after: ["h6", { text: "Offline - 1" }],
});
});

View file

@ -0,0 +1,61 @@
import {
click,
contains,
defineMailModels,
insertText,
openDiscuss,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { describe, test } from "@odoo/hoot";
import { press } from "@odoo/hoot-dom";
defineMailModels();
describe.current.tags("desktop");
test("Group name is based on channel members when name is not set", async () => {
const pyEnv = await startServer();
pyEnv["res.users"].create(
["Alice", "Bob", "Eve", "John", "Sam"].map((name) => ({
name,
partner_id: pyEnv["res.partner"].create({ name }),
}))
);
const channelId = pyEnv["discuss.channel"].create({ channel_type: "group" });
await start();
await openDiscuss(channelId);
await contains(".o-mail-DiscussContent-threadName[title='Mitchell Admin']");
await click("button[title='Invite People']");
await click(".o-discuss-ChannelInvitation-selectable", { text: "Alice" });
await click("button", { text: "Invite to Group Chat" });
await contains(".o-mail-DiscussContent-threadName[title='Mitchell Admin and Alice']");
await click("button[title='Invite People']");
await click(".o-discuss-ChannelInvitation-selectable", { text: "Bob" });
await click("button", { text: "Invite to Group Chat" });
await contains(".o-mail-DiscussContent-threadName[title='Mitchell Admin, Alice, and Bob']");
await click("button[title='Invite People']");
await click(".o-discuss-ChannelInvitation-selectable", { text: "Eve" });
await click("button", { text: "Invite to Group Chat" });
await contains(
".o-mail-DiscussContent-threadName[title='Mitchell Admin, Alice, Bob, and 1 other']"
);
await click("button[title='Invite People']");
await click(".o-discuss-ChannelInvitation-selectable", { text: "John" });
await click("button", { text: "Invite to Group Chat" });
await contains(
".o-mail-DiscussContent-threadName[title='Mitchell Admin, Alice, Bob, and 2 others']"
);
await click(".o-mail-DiscussContent-threadName");
await insertText(".o-mail-DiscussContent-threadName.o-focused", "Custom name", {
replace: true,
});
await contains(".o-mail-DiscussContent-threadName[title='Custom name']");
await press("Enter");
// Ensure that after setting the name, members are not taken into account for the group name.
await click("button[title='Invite People']");
await click(".o-discuss-ChannelInvitation-selectable", { text: "Sam" });
await click("button", { text: "Invite to Group Chat" });
await contains(".o_mail_notification", { text: "invited Sam to the channel" });
await contains(".o-mail-DiscussContent-threadName[title='Custom name']");
});

View file

@ -0,0 +1,51 @@
import {
click,
contains,
defineMailModels,
setupChatHub,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { describe, test } from "@odoo/hoot";
import { getOrigin } from "@web/core/utils/urls";
describe.current.tags("desktop");
defineMailModels();
test("clicking message link does not swap open chat window", async () => {
const pyEnv = await startServer();
const [rdId, supportId] = pyEnv["discuss.channel"].create([
{ name: "R&D" },
{ name: "Support" },
]);
const messageRdId = pyEnv["mail.message"].create({
body: "Hello R&D",
model: "discuss.channel",
res_id: rdId,
});
const urlRd = `${getOrigin()}/mail/message/${messageRdId}`;
const messageSupportId = pyEnv["mail.message"].create({
body: `Hello from there <a class="o_message_redirect" href="${urlRd}" data-oe-model="mail.message" data-oe-id="${messageRdId}">${urlRd}</a>`,
model: "discuss.channel",
res_id: supportId,
});
const urlSupport = `${getOrigin()}/mail/message/${messageSupportId}`;
pyEnv["mail.message"].create({
body: `Hello back <a class="o_message_redirect" href="${urlSupport}" data-oe-model="mail.message" data-oe-id="${messageSupportId}">${urlSupport}</a>`,
model: "discuss.channel",
res_id: rdId,
});
setupChatHub({ opened: [rdId, supportId] });
await start();
await contains(".o-mail-ChatWindow:eq(0) .o-mail-ChatWindow-header:contains(R&D)");
await contains(".o-mail-ChatWindow:eq(1) .o-mail-ChatWindow-header:contains(Support)");
await click("a.o_message_redirect:contains(R&D)");
await contains(".o-mail-Message.o-highlighted:contains(Hello R&D)");
await contains(".o-mail-ChatWindow:eq(0) .o-mail-ChatWindow-header:contains(R&D)");
await contains(".o-mail-ChatWindow:eq(1) .o-mail-ChatWindow-header:contains(Support)");
await click("a.o_message_redirect:contains(Support)");
await contains(".o-mail-Message.o-highlighted:contains(Hello from there)");
await contains(".o-mail-ChatWindow:eq(0) .o-mail-ChatWindow-header:contains(R&D)");
await contains(".o-mail-ChatWindow:eq(1) .o-mail-ChatWindow-header:contains(Support)");
});

View file

@ -0,0 +1,661 @@
import {
click,
contains,
defineMailModels,
openDiscuss,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { mailDataHelpers } from "@mail/../tests/mock_server/mail_mock_server";
import { describe, test } from "@odoo/hoot";
import { Command, serverState, withUser } from "@web/../tests/web_test_helpers";
import { rpc } from "@web/core/network/rpc";
describe.current.tags("desktop");
defineMailModels();
test("rendering when just one has received the message", async () => {
const pyEnv = await startServer();
const partnerId_1 = pyEnv["res.partner"].create({ name: "Demo User" });
const partnerId_2 = pyEnv["res.partner"].create({ name: "Other User" });
const channelId = pyEnv["discuss.channel"].create({
name: "test",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId_1 }),
Command.create({ partner_id: partnerId_2 }),
],
channel_type: "group",
});
const messageId = pyEnv["mail.message"].create({
author_id: serverState.partnerId,
body: "<p>Test</p>",
model: "discuss.channel",
res_id: channelId,
});
const [memberId_1] = pyEnv["discuss.channel.member"].search([
["channel_id", "=", channelId],
["partner_id", "=", partnerId_1],
]);
pyEnv["discuss.channel.member"].write([memberId_1], {
fetched_message_id: messageId,
seen_message_id: false,
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-MessageSeenIndicator");
await contains(".o-mail-MessageSeenIndicator[title='Sent']");
await contains(".o-mail-MessageSeenIndicator .fa-check", { count: 1 });
await contains(".o-mail-MessageSeenIndicator.o-hasEveryoneSeen", { count: 0 });
});
test("rendering when everyone have received the message", async () => {
const pyEnv = await startServer();
const partnerId_1 = pyEnv["res.partner"].create({ name: "Demo User" });
const partnerId_2 = pyEnv["res.partner"].create({ name: "Other User" });
const channelId = pyEnv["discuss.channel"].create({
name: "test",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId_1 }),
Command.create({ partner_id: partnerId_2 }),
],
channel_type: "group",
});
const messageId = pyEnv["mail.message"].create({
author_id: serverState.partnerId,
body: "<p>Test</p>",
model: "discuss.channel",
res_id: channelId,
});
const memberIds = pyEnv["discuss.channel.member"].search([["channel_id", "=", channelId]]);
pyEnv["discuss.channel.member"].write(memberIds, {
fetched_message_id: messageId,
seen_message_id: false,
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-MessageSeenIndicator");
await contains(".o-mail-MessageSeenIndicator[title='Sent']");
await contains(".o-mail-MessageSeenIndicator .fa-check", { count: 1 });
await contains(".o-mail-MessageSeenIndicator.o-hasEveryoneSeen", { count: 0 });
});
test("rendering when just one has seen the message", async () => {
const pyEnv = await startServer();
const partnerId_1 = pyEnv["res.partner"].create({ name: "Demo User" });
const partnerId_2 = pyEnv["res.partner"].create({ name: "Other User" });
const channelId = pyEnv["discuss.channel"].create({
name: "test",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId_1 }),
Command.create({ partner_id: partnerId_2 }),
],
channel_type: "group",
});
const messageId = pyEnv["mail.message"].create({
author_id: serverState.partnerId,
body: "<p>Test</p>",
model: "discuss.channel",
res_id: channelId,
});
const memberIds = pyEnv["discuss.channel.member"].search([["channel_id", "=", channelId]]);
pyEnv["discuss.channel.member"].write(memberIds, {
fetched_message_id: messageId,
seen_message_id: false,
});
const [memberId_1] = pyEnv["discuss.channel.member"].search([
["channel_id", "=", channelId],
["partner_id", "=", partnerId_1],
]);
pyEnv["discuss.channel.member"].write([memberId_1], {
seen_message_id: messageId,
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-MessageSeenIndicator");
await contains(".o-mail-MessageSeenIndicator[title='Seen by Demo User']");
await contains(".o-mail-MessageSeenIndicator .fa-check", { count: 2 });
await contains(".o-mail-MessageSeenIndicator.o-hasEveryoneSeen", { count: 0 });
});
test("rendering when just one has seen & received the message", async () => {
const pyEnv = await startServer();
const partnerId_1 = pyEnv["res.partner"].create({ name: "Demo User" });
const partnerId_2 = pyEnv["res.partner"].create({ name: "Other User" });
const channelId = pyEnv["discuss.channel"].create({
name: "test",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId_1 }),
Command.create({ partner_id: partnerId_2 }),
],
channel_type: "group",
});
const mesageId = pyEnv["mail.message"].create({
author_id: serverState.partnerId,
body: "<p>Test</p>",
model: "discuss.channel",
res_id: channelId,
});
const [memberId_1] = pyEnv["discuss.channel.member"].search([
["channel_id", "=", channelId],
["partner_id", "=", partnerId_1],
]);
pyEnv["discuss.channel.member"].write([memberId_1], {
seen_message_id: mesageId,
fetched_message_id: mesageId,
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-MessageSeenIndicator");
await contains(".o-mail-MessageSeenIndicator[title='Seen by Demo User']");
await contains(".o-mail-MessageSeenIndicator .fa-check", { count: 2 });
await contains(".o-mail-MessageSeenIndicator.o-hasEveryoneSeen", { count: 0 });
});
test("rendering when just everyone has seen the message", async () => {
const pyEnv = await startServer();
const partnerId_1 = pyEnv["res.partner"].create({ name: "Demo User" });
const partnerId_2 = pyEnv["res.partner"].create({ name: "Other User" });
const channelId = pyEnv["discuss.channel"].create({
name: "test",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId_1 }),
Command.create({ partner_id: partnerId_2 }),
],
channel_type: "group",
});
const messageId = pyEnv["mail.message"].create({
author_id: serverState.partnerId,
body: "<p>Test</p>",
model: "discuss.channel",
res_id: channelId,
});
const memberIds = pyEnv["discuss.channel.member"].search([["channel_id", "=", channelId]]);
pyEnv["discuss.channel.member"].write(memberIds, {
fetched_message_id: messageId,
seen_message_id: messageId,
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-MessageSeenIndicator");
await contains(".o-mail-MessageSeenIndicator[title='Seen by everyone']");
await contains(".o-mail-MessageSeenIndicator .fa-check", { count: 2 });
await contains(".o-mail-MessageSeenIndicator.o-hasEveryoneSeen", { count: 1 });
});
test("'channel_fetch' notification received is correctly handled", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "test" });
const channelId = pyEnv["discuss.channel"].create({
name: "test",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
channel_type: "chat",
});
pyEnv["mail.message"].create({
author_id: serverState.partnerId,
body: "<p>Test</p>",
model: "discuss.channel",
res_id: channelId,
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-Message");
await contains(".o-mail-MessageSeenIndicator .fa-check", { count: 0 });
const channel = pyEnv["discuss.channel"].search_read([["id", "=", channelId]])[0];
// Simulate received channel fetched notification
pyEnv["bus.bus"]._sendone(channel, "discuss.channel.member/fetched", {
id: pyEnv["discuss.channel.member"].search([
["channel_id", "=", channelId],
["partner_id", "=", partnerId],
])[0],
channel_id: channelId,
last_message_id: 100,
partner_id: partnerId,
});
await contains(".o-mail-MessageSeenIndicator .fa-check", { count: 1 });
});
test("mark channel as seen from the bus", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "test" });
const channelId = pyEnv["discuss.channel"].create({
name: "test",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
channel_type: "chat",
});
const messageId = pyEnv["mail.message"].create({
author_id: serverState.partnerId,
body: "<p>Test</p>",
model: "discuss.channel",
res_id: channelId,
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-Message");
await contains(".o-mail-MessageSeenIndicator .fa-check", { count: 0 });
const channel = pyEnv["discuss.channel"].search_read([["id", "=", channelId]])[0];
// Simulate received channel seen notification
const DiscussChannelMember = pyEnv["discuss.channel.member"];
pyEnv["bus.bus"]._sendone(
channel,
"mail.record/insert",
new mailDataHelpers.Store(
DiscussChannelMember.browse(
DiscussChannelMember.search([
["channel_id", "=", channelId],
["partner_id", "=", partnerId],
])
),
{ seen_message_id: messageId }
).get_result()
);
await contains(".o-mail-MessageSeenIndicator[title='Seen by test']");
await contains(".o-mail-MessageSeenIndicator .fa-check", { count: 2 });
});
test("should display message indicator when message is fetched/seen", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Recipient" });
const channelId = pyEnv["discuss.channel"].create({
name: "test",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
channel_type: "chat",
});
const messageId = pyEnv["mail.message"].create({
author_id: serverState.partnerId,
body: "<p>Test</p>",
model: "discuss.channel",
res_id: channelId,
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-Message");
await contains(".o-mail-MessageSeenIndicator .fa-check", { count: 0 });
const channel = pyEnv["discuss.channel"].search_read([["id", "=", channelId]])[0];
// Simulate received channel fetched notification
pyEnv["bus.bus"]._sendone(channel, "discuss.channel.member/fetched", {
id: pyEnv["discuss.channel.member"].search([
["channel_id", "=", channelId],
["partner_id", "=", partnerId],
])[0],
channel_id: channelId,
last_message_id: messageId,
partner_id: partnerId,
});
await contains(".o-mail-MessageSeenIndicator .fa-check", { count: 1 });
// Simulate received channel seen notification
const DiscussChannelMember = pyEnv["discuss.channel.member"];
pyEnv["bus.bus"]._sendone(
channel,
"mail.record/insert",
new mailDataHelpers.Store(
DiscussChannelMember.browse(
DiscussChannelMember.search([
["channel_id", "=", channelId],
["partner_id", "=", partnerId],
])
),
{ seen_message_id: messageId }
).get_result()
);
await contains(".o-mail-MessageSeenIndicator .fa-check", { count: 2 });
});
test("do not show message seen indicator on the last message seen by everyone when the current user is not author of the message", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Demo User" });
const channelId = pyEnv["discuss.channel"].create({
name: "test",
channel_type: "chat",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
});
const messageId = pyEnv["mail.message"].create({
author_id: partnerId,
body: "<p>Test</p>",
model: "discuss.channel",
res_id: channelId,
});
const memberIds = pyEnv["discuss.channel.member"].search([["channel_id", "=", channelId]]);
pyEnv["discuss.channel.member"].write(memberIds, { seen_message_id: messageId });
await start();
await openDiscuss(channelId);
await contains(".o-mail-Message");
await contains(".o-mail-MessageSeenIndicator", { count: 0 });
});
test("do not show message seen indicator on all the messages of the current user that are older than the last message seen by everyone", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Demo User" });
const channelId = pyEnv["discuss.channel"].create({
name: "test",
channel_type: "chat",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
});
const [, messageId_2] = pyEnv["mail.message"].create([
{
author_id: serverState.partnerId,
body: "<p>Message before last seen</p>",
model: "discuss.channel",
res_id: channelId,
},
{
author_id: serverState.partnerId,
body: "<p>Last seen by everyone</p>",
model: "discuss.channel",
res_id: channelId,
},
]);
const memberIds = pyEnv["discuss.channel.member"].search([["channel_id", "=", channelId]]);
pyEnv["discuss.channel.member"].write(memberIds, { seen_message_id: messageId_2 });
await start();
await openDiscuss(channelId);
await contains(".o-mail-Message", {
text: "Message before last seen",
contains: [".o-mail-MessageSeenIndicator", { contains: [".fa-check", { count: 0 }] }],
});
});
test("only show messaging seen indicator if authored by me, after last seen by all message", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Demo User" });
const channelId = pyEnv["discuss.channel"].create({
name: "test",
channel_type: "chat",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
});
const messageId = pyEnv["mail.message"].create({
author_id: serverState.partnerId,
body: "<p>Test</p>",
res_id: channelId,
model: "discuss.channel",
});
const memberIds = pyEnv["discuss.channel.member"].search([["channel_id", "=", channelId]]);
pyEnv["discuss.channel.member"].write(memberIds, {
fetched_message_id: messageId,
seen_message_id: messageId - 1,
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-Message");
await contains(".o-mail-MessageSeenIndicator .fa-check", { count: 1 });
});
test("all seen indicator in chat displayed only once (chat created by correspondent)", async () => {
const pyEnv = await startServer();
const demoPid = pyEnv["res.partner"].create({ name: "Demo User" });
const demoUid = pyEnv["res.users"].create({ partner_id: demoPid });
const selfPid = serverState.partnerId;
const channelId = await withUser(demoUid, () =>
pyEnv["discuss.channel"].create({
name: "test",
channel_type: "chat",
channel_member_ids: [
Command.create({ partner_id: demoPid }),
Command.create({ partner_id: selfPid }),
],
})
);
const [, messageId] = pyEnv["mail.message"].create([
{
author_id: selfPid,
body: "<p>Test1</p>",
res_id: channelId,
model: "discuss.channel",
},
{
author_id: selfPid,
body: "<p>Test2</p>",
res_id: channelId,
model: "discuss.channel",
},
]);
const memberIds = pyEnv["discuss.channel.member"].search([["channel_id", "=", channelId]]);
pyEnv["discuss.channel.member"].write(memberIds, {
fetched_message_id: messageId,
seen_message_id: messageId,
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-Message", { count: 2 });
await contains(".o-mail-MessageSeenIndicator.o-hasEveryoneSeen .fa-check", { count: 2 });
});
test("no seen indicator in 'channel' channels (with is_typing)", async () => {
// is_typing info contains fetched / seen message so this could mistakenly show seen indicators
const pyEnv = await startServer();
const demoId = pyEnv["res.partner"].create({ name: "Demo User" });
const demoUserId = pyEnv["res.users"].create({ partner_id: demoId });
const channelId = pyEnv["discuss.channel"].create({
name: "test-channel",
channel_type: "channel",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: demoId }),
],
});
const chatId = pyEnv["discuss.channel"].create({
name: "test-chat",
channel_type: "chat",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: demoId }),
],
});
const [channelMsgId, chatMsgId] = pyEnv["mail.message"].create([
{
author_id: serverState.partnerId,
body: "<p>channel-msg</p>",
res_id: channelId,
model: "discuss.channel",
},
{
author_id: serverState.partnerId,
body: "<p>chat-msg</p>",
res_id: chatId,
model: "discuss.channel",
},
]);
const channelMemberIds = pyEnv["discuss.channel.member"].search([
["channel_id", "=", channelId],
]);
const chatMemberIds = pyEnv["discuss.channel.member"].search([["channel_id", "=", chatId]]);
pyEnv["discuss.channel.member"].write(channelMemberIds, {
fetched_message_id: channelMsgId,
seen_message_id: 0,
});
pyEnv["discuss.channel.member"].write(chatMemberIds, {
fetched_message_id: chatMsgId,
seen_message_id: 0,
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-Message", { text: "channel-msg" });
await contains(".o-mail-MessageSeenIndicator .fa-check", { count: 0 }); // none in channel
await click(".o-mail-DiscussSidebar-item", { text: "Demo User" });
await contains(".o-mail-Message", { text: "chat-msg" });
await contains(".o-mail-MessageSeenIndicator .fa-check", { count: 1 }); // received in chat
// simulate channel read by Demo User in both threads
await withUser(demoUserId, () =>
rpc("/discuss/channel/mark_as_read", {
channel_id: channelId,
last_message_id: channelMsgId,
})
);
await withUser(demoUserId, () =>
rpc("/discuss/channel/mark_as_read", {
channel_id: chatId,
last_message_id: chatMsgId,
})
);
// simulate typing by Demo User in both threads
await withUser(demoUserId, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: channelId,
is_typing: true,
})
);
await withUser(demoUserId, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: chatId,
is_typing: true,
})
);
await contains(".o-mail-MessageSeenIndicator .fa-check", { count: 2 }); // seen in chat
await click(".o-mail-DiscussSidebar-item", { text: "test-channel" });
await contains(".o-mail-Message", { text: "channel-msg" });
await contains(".o-mail-MessageSeenIndicator .fa-check", { count: 0 }); // none in channel
});
test("Show everyone seen title on message seen indicator", async () => {
const pyEnv = await startServer();
const partnerId_1 = pyEnv["res.partner"].create({ name: "Demo User" });
const partnerId_2 = pyEnv["res.partner"].create({ name: "Other User" });
const channelId = pyEnv["discuss.channel"].create({
name: "test",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId, last_seen_dt: "2024-06-01 12:00" }),
Command.create({ partner_id: partnerId_1, last_seen_dt: "2024-06-01 12:00" }),
Command.create({ partner_id: partnerId_2, last_seen_dt: "2024-06-01 13:00" }),
],
channel_type: "group",
});
const mesageId = pyEnv["mail.message"].create({
author_id: serverState.partnerId,
body: "<p>Test</p>",
model: "discuss.channel",
res_id: channelId,
});
const [memberId_1, memberId_2] = pyEnv["discuss.channel.member"].search([
["channel_id", "=", channelId],
["partner_id", "in", [partnerId_1, partnerId_2]],
]);
pyEnv["discuss.channel.member"].write([memberId_1], {
seen_message_id: mesageId,
fetched_message_id: mesageId,
});
pyEnv["discuss.channel.member"].write([memberId_2], {
seen_message_id: mesageId,
fetched_message_id: mesageId,
});
await start();
await openDiscuss(channelId);
await contains("[title='Seen by everyone']");
});
test("Title show some member seen info (partial seen), click show dialog with full info", async () => {
// last member flagged as not seen so that it doesn't show "Seen by everyone" but list names instead
const pyEnv = await startServer();
const partners = [];
for (let i = 0; i < 12; i++) {
partners.push({ name: `User ${i}` });
}
const partnerIds = pyEnv["res.partner"].create(partners);
const channelMemberIds = [];
for (const partner_id of partnerIds) {
channelMemberIds.push(
Command.create({
partner_id,
last_seen_dt: partner_id === partnerIds.at(-1) ? false : "2024-06-01 12:00",
})
);
}
const channelId = pyEnv["discuss.channel"].create({
name: "test",
channel_member_ids: [
Command.create({
partner_id: serverState.partnerId,
last_seen_dt: "2024-06-01 12:00",
}),
...channelMemberIds,
],
channel_type: "group",
});
const mesageId = pyEnv["mail.message"].create({
author_id: serverState.partnerId,
body: "<p>Test</p>",
model: "discuss.channel",
res_id: channelId,
});
const members = pyEnv["discuss.channel.member"].search([
["channel_id", "=", channelId],
["partner_id", "in", partnerIds.filter((p) => p !== partnerIds.at(-1))],
]);
pyEnv["discuss.channel.member"].write(members, {
seen_message_id: mesageId,
fetched_message_id: mesageId,
});
await start();
await openDiscuss(channelId);
await contains("[title='Seen by User 0, User 1, User 2 and 8 others']");
await click(".o-mail-MessageSeenIndicator");
await contains("li", { count: 11 });
for (let i = 0; i < 11; i++) {
await contains("li", { text: `User ${i}` }); // Not checking datetime because HOOT mocking of tz do not work
}
});
test("Show seen indicator on message with only attachment", async () => {
const pyEnv = await startServer();
const partnerId_1 = pyEnv["res.partner"].create({ name: "Demo User" });
const channelId = pyEnv["discuss.channel"].create({
name: "test",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId_1 }),
],
channel_type: "group",
});
const attachmentId = pyEnv["ir.attachment"].create({
name: "test.txt",
mimetype: "text/plain",
});
const messageId = pyEnv["mail.message"].create({
author_id: serverState.partnerId,
body: "",
model: "discuss.channel",
res_id: channelId,
attachment_ids: [attachmentId],
});
const memberIds = pyEnv["discuss.channel.member"].search([["channel_id", "=", channelId]]);
pyEnv["discuss.channel.member"].write(memberIds, {
fetched_message_id: messageId,
seen_message_id: false,
});
const [memberId_1] = pyEnv["discuss.channel.member"].search([
["channel_id", "=", channelId],
["partner_id", "=", partnerId_1],
]);
pyEnv["discuss.channel.member"].write([memberId_1], {
seen_message_id: messageId,
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-MessageSeenIndicator");
await contains(".o-mail-MessageSeenIndicator .fa-check", { count: 2 });
});

View file

@ -0,0 +1,128 @@
import { insertText as htmlInsertText } from "@html_editor/../tests/_helpers/user_actions";
import {
click,
contains,
defineMailModels,
focus,
insertText,
onRpcBefore,
openDiscuss,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { Composer } from "@mail/core/common/composer";
import { beforeEach, describe, test } from "@odoo/hoot";
import {
asyncStep,
getService,
patchWithCleanup,
waitForSteps,
} from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
defineMailModels();
beforeEach(() => {
// Simulate real user interactions
patchWithCleanup(Composer.prototype, {
isEventTrusted() {
return true;
},
});
});
test('do not send typing notification on typing "/" command', async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "channel" });
let testEnded = false;
onRpcBefore("/discuss/channel/notify_typing", () => {
if (!testEnded) {
asyncStep("notify_typing");
}
});
await start();
await openDiscuss(channelId);
await insertText(".o-mail-Composer-input", "/");
await contains(".o-mail-Composer button[title='Send']:enabled");
await waitForSteps([]); // No rpc done
testEnded = true;
});
test('do not send typing notification on typing after selecting suggestion from "/" command', async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "channel" });
let testEnded = false;
onRpcBefore("/discuss/channel/notify_typing", () => {
if (!testEnded) {
asyncStep("notify_typing");
}
});
await start();
await openDiscuss(channelId);
await insertText(".o-mail-Composer-input", "/");
await click(":nth-child(1 of .o-mail-Composer-suggestion)");
await contains(".o-mail-Composer-suggestion strong", { count: 0 });
await insertText(".o-mail-Composer-input", " is user?");
await waitForSteps([]); // No rpc done"
testEnded = true;
});
test("send is_typing on adding emoji", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "channel" });
let testEnded = false;
onRpcBefore("/discuss/channel/notify_typing", () => {
if (!testEnded) {
asyncStep("notify_typing");
}
});
await start();
await openDiscuss(channelId);
await click("button[title='Add Emojis']");
await insertText("input[placeholder='Search emoji']", "Santa Claus");
await click(".o-Emoji", { text: "🎅" });
await waitForSteps(["notify_typing"]);
testEnded = true;
});
test("add an emoji after a command", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({
name: "General",
channel_type: "channel",
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-Composer-input", { value: "" });
await insertText(".o-mail-Composer-input", "/");
await click(":nth-child(1 of .o-mail-Composer-suggestion)");
await contains(".o-mail-Composer-input", { value: "/who " });
await click("button[title='Add Emojis']");
await click(".o-Emoji", { text: "😊" });
await contains(".o-mail-Composer-input", { value: "/who 😊" });
});
test.tags("html composer");
test("html composer: send a message in a channel", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({
name: "General",
channel_type: "channel",
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-Composer-input", { value: "" });
const composerService = getService("mail.composer");
composerService.setHtmlComposer();
await focus(".o-mail-Composer-html.odoo-editor-editable");
const editor = {
document,
editable: document.querySelector(".o-mail-Composer-html.odoo-editor-editable"),
};
await htmlInsertText(editor, "Hello");
await contains(".o-mail-Composer-html.odoo-editor-editable", { text: "Hello" });
await click(".o-mail-Composer button[title='Send']:enabled");
await click(".o-mail-Message[data-persistent]:contains(Hello)");
await contains(".o-mail-Composer-html.odoo-editor-editable", { text: "" });
});

View file

@ -0,0 +1,53 @@
import {
click,
contains,
defineMailModels,
openDiscuss,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { describe, test } from "@odoo/hoot";
import { Command, getService, serverState, withUser } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
defineMailModels();
test("Add member to channel", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
const userId = pyEnv["res.users"].create({ name: "Harry" });
pyEnv["res.partner"].create({ name: "Harry", user_ids: [userId] });
await start();
await openDiscuss(channelId);
await contains(".o-discuss-ChannelMemberList"); // wait for auto-open of this panel
await contains(".o-discuss-ChannelMember", { text: "Mitchell Admin" });
await click("[title='Invite People']");
await click(".o-discuss-ChannelInvitation-selectable", { text: "Harry" });
await click(".o-discuss-ChannelInvitation [title='Invite']:enabled");
await contains(".o-discuss-ChannelInvitation", { count: 0 });
await click("[title='Members']");
await contains(".o-discuss-ChannelMember", { text: "Harry" });
});
test("Remove member from channel", async () => {
const pyEnv = await startServer();
const userId = pyEnv["res.users"].create({ name: "Harry" });
const partnerId = pyEnv["res.partner"].create({
name: "Harry",
user_ids: [userId],
});
const channelId = pyEnv["discuss.channel"].create({
name: "General",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
});
await start();
await openDiscuss(channelId);
await contains(".o-discuss-ChannelMember", { text: "Harry" });
withUser(userId, () =>
getService("orm").call("discuss.channel", "action_unfollow", [channelId])
);
await contains(".o-discuss-ChannelMember", { count: 0, text: "Harry" });
});

View file

@ -0,0 +1,53 @@
import { onWebsocketEvent } from "@bus/../tests/mock_websocket";
import {
click,
contains,
defineMailModels,
openDiscuss,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { describe, expect, test } from "@odoo/hoot";
import { tick } from "@odoo/hoot-dom";
import { makeMockEnv } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
defineMailModels();
test("Member list and Pinned Messages Panel menu are exclusive", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
await start();
await openDiscuss(channelId);
await contains(".o-discuss-ChannelMemberList"); // member list open by default
await click("[title='Pinned Messages']");
await contains(".o-discuss-PinnedMessagesPanel");
await contains(".o-discuss-ChannelMemberList", { count: 0 });
});
test("subscribe to presence channels according to store data", async () => {
const env = await makeMockEnv();
const store = env.services["mail.store"];
onWebsocketEvent("subscribe", (data) => expect.step(`subscribe - [${data.channels}]`));
expect(env.services.bus_service.isActive).toBe(false);
// Should not subscribe to presences as bus service is not started.
store["res.partner"].insert({ id: 1, name: "Partner 1" });
store["res.partner"].insert({ id: 2, name: "Partner 2" });
await tick();
expect.waitForSteps([]);
// Starting the bus should subscribe to known presence channels.
env.services.bus_service.start();
await expect.waitForSteps([
"subscribe - [odoo-presence-res.partner_1,odoo-presence-res.partner_2]",
]);
// Discovering new presence channels should refresh the subscription.
store["mail.guest"].insert({ id: 1 });
await expect.waitForSteps([
"subscribe - [odoo-presence-mail.guest_1,odoo-presence-res.partner_1,odoo-presence-res.partner_2]",
]);
// Updating "im_status_access_token" should refresh the subscription.
store["mail.guest"].insert({ id: 1, im_status_access_token: "token" });
await expect.waitForSteps([
"subscribe - [odoo-presence-mail.guest_1-token,odoo-presence-res.partner_1,odoo-presence-res.partner_2]",
]);
});

View file

@ -0,0 +1,111 @@
import {
click,
contains,
defineMailModels,
insertText,
observeRenders,
openDiscuss,
prepareObserveRenders,
start,
startServer,
triggerHotkey,
} from "@mail/../tests/mail_test_helpers";
import { Composer } from "@mail/core/common/composer";
import { Message } from "@mail/core/common/message";
import { describe, expect, test } from "@odoo/hoot";
import { onMounted, onPatched } from "@odoo/owl";
import { patchWithCleanup } from "@web/../tests/web_test_helpers";
import { range } from "@web/core/utils/numbers";
describe.current.tags("desktop");
defineMailModels();
test("posting new message should only render relevant part", async () => {
// For example, it should not render old messages again
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "general" });
const messageIds = [];
for (let i = 0; i < 10; i++) {
messageIds.push(
pyEnv["mail.message"].create({
body: `not_empty_${i}`,
model: "discuss.channel",
res_id: channelId,
})
);
}
messageIds.pop(); // remove last as it might need re-render (it was the newest message before)
let posting = false;
prepareObserveRenders();
patchWithCleanup(Message.prototype, {
setup() {
const cb = () => {
if (posting) {
if (messageIds.includes(this.message.id)) {
throw new Error(
"Should not re-render old messages again on posting a new message"
);
}
}
};
onMounted(cb);
onPatched(cb);
return super.setup();
},
});
await start();
const stopObserve1 = observeRenders();
await openDiscuss(channelId);
await contains(".o-mail-Message", { count: 10 });
await insertText(".o-mail-Composer-input", "Test");
const result1 = stopObserve1();
// LessThan because renders could be batched
expect(result1.get(Message)).toBeLessThan(11); // 10: all messages initially
const stopObserve2 = observeRenders();
posting = true;
triggerHotkey("Enter");
await contains(".o-mail-Message", { count: 11 });
posting = false;
const result2 = stopObserve2();
expect(result2.get(Composer)).toBeLessThan(3); // 2: temp disabling + clear content
expect(result2.get(Message)).toBeLessThan(4); // 3: new temp msg + new genuine msg + prev msg
});
test("replying to message should only render relevant part", async () => {
// For example, it should not render all messages when selecting message to reply
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "general" });
const messageIds = range(0, 10).map((i) =>
pyEnv["mail.message"].create({ body: `${i}`, model: "discuss.channel", res_id: channelId })
);
messageIds.pop(); // remove last as this is the one to be replied to
let replying = false;
prepareObserveRenders();
patchWithCleanup(Message.prototype, {
setup() {
const cb = () => {
if (replying) {
if (messageIds.includes(this.message.id)) {
throw new Error(
"Should not re-render other messages on replying to a message"
);
}
}
};
onMounted(cb);
onPatched(cb);
return super.setup();
},
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-Message", { count: 10 });
const stopObserve = observeRenders();
replying = true;
await click(".o-mail-Message:last [title='Reply']");
await contains(".o-mail-Composer:has(:text('Replying to Mitchell Admin'))");
replying = false;
const result = stopObserve();
expect(result.get(Composer)).toBeLessThan(2);
expect(result.get(Message)).toBeLessThan(2);
});

View file

@ -0,0 +1,62 @@
import { waitUntilSubscribe } from "@bus/../tests/bus_test_helpers";
import {
contains,
defineMailModels,
openDiscuss,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { describe, expect, test } from "@odoo/hoot";
import { Command, mockService, patchWithCleanup, withUser } from "@web/../tests/web_test_helpers";
import { rpc } from "@web/core/network/rpc";
describe.current.tags("desktop");
defineMailModels();
test("open channel in discuss from push notification", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
await start();
await openDiscuss("mail.box_inbox");
await contains(".o-mail-DiscussContent-threadName[title='Inbox']");
navigator.serviceWorker.dispatchEvent(
new MessageEvent("message", {
data: { action: "OPEN_CHANNEL", data: { id: channelId } },
})
);
await contains(".o-mail-DiscussContent-threadName[title='General']");
});
test("notify message to user as non member", async () => {
patchWithCleanup(window, {
Notification: class Notification {
static get permission() {
return "granted";
}
constructor() {
expect.step("push notification");
}
addEventListener() {}
},
});
mockService("multi_tab", { isOnMainTab: () => true });
const pyEnv = await startServer();
const johnUser = pyEnv["res.users"].create({ name: "John" });
const johnPartner = pyEnv["res.partner"].create({ name: "John", user_ids: [johnUser] });
const channelId = pyEnv["discuss.channel"].create({
channel_type: "chat",
channel_member_ids: [Command.create({ partner_id: johnPartner })],
});
await start();
await Promise.all([openDiscuss(channelId), waitUntilSubscribe(`discuss.channel_${channelId}`)]);
await withUser(johnUser, () =>
rpc("/mail/message/post", {
post_data: { body: "Hello!", message_type: "comment" },
thread_id: channelId,
thread_model: "discuss.channel",
})
);
await contains(".o-mail-Message", { text: "Hello!" });
expect.verifySteps(["push notification"]);
});

View file

@ -0,0 +1,376 @@
import {
click,
contains,
defineMailModels,
insertText,
onRpcAfter,
openDiscuss,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { describe, test } from "@odoo/hoot";
import { Deferred, mockDate, animationFrame } from "@odoo/hoot-mock";
import { Command, serverState, withUser } from "@web/../tests/web_test_helpers";
import { rpc } from "@web/core/network/rpc";
describe.current.tags("desktop");
defineMailModels();
test("navigate to sub channel", async () => {
mockDate("2025-01-01 12:00:00", +1);
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
await start();
await openDiscuss(channelId);
// Should access sub-thread after its creation.
await contains(".o-mail-DiscussContent-threadName", { value: "General" });
await click("button[title='Threads']");
await click("button[aria-label='Create Thread']");
await contains(".o-mail-DiscussContent-threadName", { value: "New Thread" });
// Should access sub-thread when clicking on the menu.
await click(".o-mail-DiscussSidebarChannel", { name: "General" });
await contains(".o-mail-DiscussContent-threadName", { value: "General" });
await click("button[title='Threads']");
await click(".o-mail-SubChannelPreview", { text: "New Thread" });
await contains(".o-mail-DiscussContent-threadName", { value: "New Thread" });
// Should access sub-thread when clicking on the notification.
await click(".o-mail-DiscussSidebarChannel", { name: "General" });
await contains(".o-mail-DiscussContent-threadName", { value: "New Thread" });
await contains(".o-mail-NotificationMessage", {
text: `${serverState.partnerName} started a thread: New Thread.1:00 PM`,
});
await click(".o-mail-NotificationMessage a", { text: "New Thread" });
await contains(".o-mail-DiscussContent-threadName", { value: "New Thread" });
});
test("can manually unpin a sub-thread", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
await start();
await openDiscuss(channelId);
// Open thread so this is pinned
await contains(".o-mail-DiscussContent-threadName", { value: "General" });
await click("button[title='Threads']");
await click("button[aria-label='Create Thread']");
await contains(".o-mail-DiscussContent-threadName", { value: "New Thread" });
await click("[title='Thread Actions']");
await click(".o-dropdown-item:contains('Unpin Conversation')");
await contains(".o-mail-DiscussSidebar-item", { text: "New Thread", count: 0 });
});
test("create sub thread from existing message", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
pyEnv["mail.message"].create({
model: "discuss.channel",
res_id: channelId,
body: "<p>Selling a training session and selling the products after the training session is more efficient.</p>",
});
await start();
await openDiscuss(channelId);
await click(".o-mail-Message-actions [title='Expand']");
await click(".o-dropdown-item:contains('Create Thread')");
await contains(".o-mail-DiscussContent-threadName", {
value: "Selling a training session and",
});
await contains(".o-mail-Message", {
text: "Selling a training session and selling the products after the training session is more efficient.",
});
await click(".o-mail-DiscussSidebarChannel", { name: "General" });
await click(".o-mail-Message-actions [title='Expand']");
await contains(".o-dropdown-item:contains('Create Thread')", { count: 0 });
await contains(".o-mail-SubChannelPreview:contains('Selling a training session and')");
await click(".o-mail-SubChannelPreview:contains('Selling a training session and')");
await contains(".o-mail-DiscussContent-threadName", {
value: "Selling a training session and",
});
await contains(".o-mail-SubChannelPreview:contains('Selling a training session and')", {
count: 0,
});
});
test("should allow creating a thread from an existing thread", async () => {
mockDate("2025-01-01 12:00:00", +1);
const pyEnv = await startServer();
const parent_channel_id = pyEnv["discuss.channel"].create({ name: "General" });
const sub_channel_id = pyEnv["discuss.channel"].create({
name: "sub channel",
parent_channel_id: parent_channel_id,
});
pyEnv["mail.message"].create({
model: "discuss.channel",
res_id: sub_channel_id,
body: "<p>hello alex</p>",
});
await start();
await openDiscuss(sub_channel_id);
await click(".o-mail-Message-actions [title='Expand']");
await click(".o-dropdown-item:contains('Create Thread')");
await contains(".o-mail-DiscussContent-threadName", { value: "hello alex" });
await click(".o-mail-DiscussSidebarChannel", { name: "General" });
await contains(".o-mail-NotificationMessage", {
text: `${serverState.partnerName} started a thread: hello alex.1:00 PM`,
});
});
test("create sub thread from existing message (slow network)", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
pyEnv["mail.message"].create({
model: "discuss.channel",
res_id: channelId,
body: "<p>Selling a training session and selling the products after the training session is more efficient.</p>",
});
const createSubChannelDef = new Deferred();
onRpcAfter("/discuss/channel/sub_channel/create", async () => await createSubChannelDef);
await start();
await openDiscuss(channelId);
await click(".o-mail-Message-actions [title='Expand']");
await click(".o-dropdown-item:contains('Create Thread')");
await animationFrame();
createSubChannelDef.resolve();
await contains(".o-mail-DiscussContent-threadName", {
value: "Selling a training session and",
});
await contains(".o-mail-Message", {
text: "Selling a training session and selling the products after the training session is more efficient.",
});
});
test("create sub thread from sub-thread list", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
await start();
await openDiscuss(channelId);
await contains(".o-discuss-ChannelMemberList"); // wait for auto-open of this panel
await click("button[title='Threads']");
await contains(".o-mail-SubChannelList", { text: "This channel has no thread yet." });
await click("button[aria-label='Create Thread']");
await contains(".o-mail-DiscussContent-threadName", { value: "New Thread" });
await click(".o-mail-DiscussSidebarChannel", { name: "General" });
await contains(".o-mail-DiscussContent-threadName", { value: "General" });
await click(".o-mail-DiscussContent-header button[title='Threads']");
await insertText(
".o-mail-ActionPanel:has(.o-mail-SubChannelList) .o_searchview_input",
"MyEpicThread"
);
await click("button[aria-label='Search button']");
await contains(".o-mail-SubChannelList", { text: 'No thread named "MyEpicThread"' });
await click("button[aria-label='Create Thread']");
await contains(".o-mail-DiscussContent-threadName", { value: "MyEpicThread" });
});
test("'Thread' menu available in threads", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({
name: "General",
});
const subChannelID = pyEnv["discuss.channel"].create({
name: "ThreadOne",
parent_channel_id: channelId,
});
await start();
await openDiscuss(subChannelID);
await click(".o-mail-DiscussSidebar-item", { text: "ThreadOne" });
await contains(".o-mail-DiscussContent-threadName", { value: "ThreadOne" });
await click("button[title='Threads']");
await insertText(".o-mail-ActionPanel input[placeholder='Search by name']", "ThreadTwo");
await click(".o-mail-ActionPanel button", { text: "Create" });
await click(".o-mail-DiscussSidebar-item", { text: "ThreadTwo" });
});
test("sub thread is available for channel and group, not for chat", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Demo" });
const channelId = pyEnv["discuss.channel"].create({
name: "General",
});
pyEnv["discuss.channel"].create({
name: "Group",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
channel_type: "group",
});
pyEnv["discuss.channel"].create({
channel_type: "chat",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
});
await start();
await openDiscuss(channelId);
await contains(".o-discuss-ChannelMemberList"); // wait for auto-open of this panel
await click("button[title='Threads']");
await insertText(
".o-mail-ActionPanel input[placeholder='Search by name']",
"Sub thread for channel"
);
await click(".o-mail-ActionPanel button", { text: "Create" });
await click(".o-mail-DiscussSidebar-item", { text: "Sub thread for channel" });
await click(".o-mail-DiscussSidebarChannel", { text: "Group" });
await contains(".o-mail-DiscussContent-threadName", { value: "Group" });
await click("button[title='Threads']");
await insertText(
".o-mail-ActionPanel input[placeholder='Search by name']",
"Sub thread for group"
);
await click(".o-mail-ActionPanel button", { text: "Create" });
await click(".o-mail-DiscussSidebar-item", { text: "Sub thread for group" });
await click(".o-mail-DiscussSidebarChannel", { text: "Demo" });
await contains("button[title='Threads']", { count: 0 });
});
test("mention suggestions in thread match channel restrictions", async () => {
const pyEnv = await startServer();
const groupId = pyEnv["res.groups"].create({ name: "testGroup" });
const channelId = pyEnv["discuss.channel"].create({
name: "General",
group_public_id: groupId,
});
pyEnv["discuss.channel"].create({
name: "Thread",
parent_channel_id: channelId,
});
pyEnv["res.users"].write(serverState.userId, { group_ids: [Command.link(groupId)] });
const [partnerId_1, partnerId_2] = pyEnv["res.partner"].create([
{ email: "p1@odoo.com", name: "p1" },
{ email: "p2@odoo.com", name: "p2" },
]);
pyEnv["res.users"].create([
{ partner_id: partnerId_1, group_ids: [Command.link(groupId)] },
{ partner_id: partnerId_2 },
]);
await start();
await openDiscuss(channelId);
await contains(".o-mail-DiscussSidebar-item.o-active:contains('General')");
await insertText(".o-mail-Composer-input", "@");
await contains(".o-mail-Composer-suggestion", { count: 2 });
await contains(".o-mail-Composer-suggestion", { text: "Mitchell Admin" });
await contains(".o-mail-Composer-suggestion", { text: "p1" });
await click(".o-mail-DiscussSidebar-item:contains('Thread')");
await contains(".o-mail-DiscussSidebar-item.o-active:contains('Thread')");
await insertText(".o-mail-Composer-input", "@");
await contains(".o-mail-Composer-suggestion", { count: 2 });
await contains(".o-mail-Composer-suggestion", { text: "Mitchell Admin" });
await contains(".o-mail-Composer-suggestion", { text: "p1" });
});
test("sub-thread is visually muted when mute is active", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
await start();
await openDiscuss(channelId);
await contains(".o-mail-DiscussContent-threadName", { value: "General" });
await click("button[title='Threads']");
await click("button[aria-label='Create Thread']");
await contains(".opacity-50.o-mail-DiscussSidebar-item:contains('New Thread')", { count: 0 });
await click(".o-mail-DiscussSidebar-item:contains('New Thread')");
await click("button[title='Notification Settings']");
await click("button:contains('Mute Conversation')");
await click("button:contains('Until I turn it back on')");
await contains(".opacity-50.o-mail-DiscussSidebar-item:contains('New Thread')");
});
test("muted channel hides sub-thread unless channel is selected or thread has unread messages", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
const partnerId2 = pyEnv["res.partner"].create({ email: "p1@odoo.com", name: "p1" });
const userId2 = pyEnv["res.users"].create({ name: "User 2", partner_id: partnerId2 });
const partnerId = serverState.partnerId;
const subChannelId = pyEnv["discuss.channel"].create({
name: "New Thread",
parent_channel_id: channelId,
channel_member_ids: [
Command.create({ partner_id: partnerId }),
Command.create({ partner_id: partnerId2 }),
],
});
pyEnv["discuss.channel"].create({ name: "Other" });
await start();
await openDiscuss(channelId);
await click(".o-mail-DiscussSidebar-item:contains('General')");
await click("button[title='Notification Settings']");
await click("button:contains('Mute Conversation')");
await click("button:contains('Until I turn it back on')");
await click(".o-mail-DiscussSidebar-item:contains('Other')");
await contains(".o-mail-DiscussSidebar-item:contains('New Thread')", { count: 0 });
await click(".o-mail-DiscussSidebar-item:contains('General')");
await contains(".o-mail-DiscussSidebar-item:contains('New Thread')");
await click(".o-mail-DiscussSidebar-item:contains('Other')");
await contains(".o-mail-DiscussSidebar-item:contains('New Thread')", { count: 0 });
withUser(userId2, () =>
rpc("/mail/message/post", {
post_data: { body: "Some message", message_type: "comment" },
thread_id: subChannelId,
thread_model: "discuss.channel",
})
);
await contains(".o-mail-DiscussSidebar-item:contains('New Thread')");
});
test("show notification when clicking on deleted thread", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "Test Channel" });
const activeThreadId = pyEnv["discuss.channel"].create({
name: "Message 1",
parent_channel_id: channelId,
});
pyEnv["mail.message"].create({
author_id: serverState.partnerId,
body: `<div class="o_mail_notification"> started a thread:<a href="#" class="o_channel_redirect" data-oe-id="${activeThreadId}" data-oe-model="discuss.channel">Message 1</a></div>`,
message_type: "notification",
model: "discuss.channel",
res_id: channelId,
});
pyEnv["discuss.channel"].unlink(activeThreadId);
await start();
await openDiscuss(channelId);
await click(".o-mail-NotificationMessage a", { text: "Message 1" });
await contains(".o_notification:has(.o_notification_bar.bg-danger)", {
text: "This thread is no longer available.",
});
});
test("Can delete channel thread as author of thread", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
const subChannelID = pyEnv["discuss.channel"].create({
name: "test thread",
parent_channel_id: channelId,
});
await start();
await openDiscuss(subChannelID);
await contains(".o-mail-DiscussContent-threadName[title='test thread']");
await click(".o-mail-DiscussSidebar-item:contains('test thread') [title='Thread Actions']");
await click(".o-dropdown-item:contains('Delete Thread')");
await click(".modal button:contains('Delete Thread')");
await contains(".o-mail-DiscussContent-threadName[title='General']");
await contains(
`.o-mail-NotificationMessage :text(Mitchell Admin deleted the thread "test thread")`
);
});
test("can mention all group chat members inside its sub-thread", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Lilibeth" });
const groupChannelId = pyEnv["discuss.channel"].create({
name: "Our channel",
channel_type: "group",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
});
const groupSubChannelId = pyEnv["discuss.channel"].create({
name: "New Thread",
parent_channel_id: groupChannelId,
channel_member_ids: [Command.create({ partner_id: serverState.partnerId })],
});
await start();
await openDiscuss(groupSubChannelId);
await insertText(".o-mail-Composer-input", "@");
await contains(".o-mail-Composer-suggestion", { count: 2 });
});

View file

@ -0,0 +1,319 @@
import { insertText as htmlInsertText } from "@html_editor/../tests/_helpers/user_actions";
import {
click,
contains,
defineMailModels,
insertText,
openDiscuss,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { beforeEach, describe, test } from "@odoo/hoot";
import { mockDate } from "@odoo/hoot-mock";
import { Command, getService, patchWithCleanup, serverState } from "@web/../tests/web_test_helpers";
import { Composer } from "@mail/core/common/composer";
import { press } from "@odoo/hoot-dom";
describe.current.tags("desktop");
defineMailModels();
beforeEach(() => {
// Simulate real user interactions
patchWithCleanup(Composer.prototype, {
isEventTrusted() {
return true;
},
});
});
test('[text composer] display command suggestions on typing "/"', async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({
name: "General",
channel_type: "channel",
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-Composer-suggestionList");
await contains(".o-mail-Composer-suggestionList .o-open", { count: 0 });
await insertText(".o-mail-Composer-input", "/");
await contains(".o-mail-Composer-suggestionList .o-open");
});
test.tags("html composer");
test("display command suggestions on typing '/'", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({
name: "General",
channel_type: "channel",
});
await start();
const composerService = getService("mail.composer");
composerService.setHtmlComposer();
await openDiscuss(channelId);
await contains(".o-mail-Composer-suggestionList");
await contains(".o-mail-Composer-suggestionList .o-open", { count: 0 });
await focus(".o-mail-Composer-html.odoo-editor-editable");
const editor = {
document,
editable: document.querySelector(".o-mail-Composer-html.odoo-editor-editable"),
};
await htmlInsertText(editor, "/");
await contains(".o-mail-Composer-suggestionList .o-open");
});
test("[text composer] use a command for a specific channel type", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ channel_type: "chat" });
await start();
await openDiscuss(channelId);
await contains(".o-mail-Composer-suggestionList");
await contains(".o-mail-Composer-suggestionList .o-open", { count: 0 });
await contains(".o-mail-Composer-input", { value: "" });
await insertText(".o-mail-Composer-input", "/");
await click(".o-mail-Composer-suggestion strong", { text: "who" });
await contains(".o-mail-Composer-input", { value: "/who " });
});
test.tags("html composer");
test("use a command for a specific channel type", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ channel_type: "chat" });
await start();
const composerService = getService("mail.composer");
composerService.setHtmlComposer();
await openDiscuss(channelId);
await contains(".o-mail-Composer-suggestionList");
await contains(".o-mail-Composer-suggestionList .o-open", { count: 0 });
await focus(".o-mail-Composer-html.odoo-editor-editable");
const editor = {
document,
editable: document.querySelector(".o-mail-Composer-html.odoo-editor-editable"),
};
await htmlInsertText(editor, "/");
await click(".o-mail-Composer-suggestion strong", { text: "who" });
await contains(".o-mail-Composer-html.odoo-editor-editable", { text: "/who" });
});
test("[text composer] command suggestion should only open if command is the first character", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({
name: "General",
channel_type: "channel",
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-Composer-suggestionList");
await contains(".o-mail-Composer-suggestionList .o-open", { count: 0 });
await contains(".o-mail-Composer-input", { value: "" });
await insertText(".o-mail-Composer-input", "bluhbluh ");
await contains(".o-mail-Composer-input", { value: "bluhbluh " });
await insertText(".o-mail-Composer-input", "/");
// weak test, no guarantee that we waited long enough for the potential list to open
await contains(".o-mail-Composer-suggestionList .o-open", { count: 0 });
});
test.tags("html composer");
test("command suggestion should only open if command is the first character", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({
name: "General",
channel_type: "channel",
});
await start();
const composerService = getService("mail.composer");
composerService.setHtmlComposer();
await openDiscuss(channelId);
await contains(".o-mail-Composer-suggestionList");
await contains(".o-mail-Composer-suggestionList .o-open", { count: 0 });
await focus(".o-mail-Composer-html.odoo-editor-editable");
const editor = {
document,
editable: document.querySelector(".o-mail-Composer-html.odoo-editor-editable"),
};
await htmlInsertText(editor, "bluhbluh");
await contains(".o-mail-Composer-html.odoo-editor-editable", { text: "bluhbluh" });
await htmlInsertText(editor, "/");
// weak test, no guarantee that we waited long enough for the potential list to open
await contains(".o-mail-Composer-suggestionList .o-open", { count: 0 });
});
test("Sort partner suggestions by recent chats", async () => {
mockDate("2023-01-03 12:00:00"); // so that it's after last interest (mock server is in 2019 by default!)
const pyEnv = await startServer();
const [partner_1, partner_2, partner_3] = pyEnv["res.partner"].create([
{ name: "User 1" },
{ name: "User 2" },
{ name: "User 3" },
]);
pyEnv["res.users"].create([
{ partner_id: partner_1 },
{ partner_id: partner_2 },
{ partner_id: partner_3 },
]);
pyEnv["discuss.channel"].create([
{
name: "General",
channel_type: "channel",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partner_1 }),
Command.create({ partner_id: partner_2 }),
Command.create({ partner_id: partner_3 }),
],
},
{
channel_member_ids: [
Command.create({
last_interest_dt: "2023-01-01 00:00:00",
partner_id: serverState.partnerId,
}),
Command.create({ partner_id: partner_1 }),
],
channel_type: "chat",
},
{
channel_member_ids: [
Command.create({
last_interest_dt: "2023-01-01 00:00:10",
partner_id: serverState.partnerId,
}),
Command.create({ partner_id: partner_2 }),
],
channel_type: "chat",
},
{
channel_member_ids: [
Command.create({
last_interest_dt: "2023-01-01 00:00:20",
partner_id: serverState.partnerId,
}),
Command.create({ partner_id: partner_3 }),
],
channel_type: "chat",
},
]);
await start();
await openDiscuss();
await click(".o-mail-DiscussSidebarChannel", { text: "User 2" });
await insertText(".o-mail-Composer-input", "This is a test");
await press("Enter");
await contains(".o-mail-Message-content", { text: "This is a test" });
await click(".o-mail-DiscussSidebarChannel", { text: "General" });
await contains(
".o-mail-DiscussSidebarCategory-chat + .o-mail-DiscussSidebarChannel-container:text(User 2)"
);
await insertText(".o-mail-Composer-input[placeholder='Message #General…']", "@");
await insertText(".o-mail-Composer-input", "User");
await contains(".o-mail-Composer-suggestion strong", { count: 3 });
await contains(":nth-child(1 of .o-mail-Composer-suggestion) strong", { text: "User 2" });
await contains(":nth-child(2 of .o-mail-Composer-suggestion) strong", { text: "User 3" });
await contains(":nth-child(3 of .o-mail-Composer-suggestion) strong", { text: "User 1" });
});
test("mention suggestion are shown after deleting a character", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
const channelId = pyEnv["discuss.channel"].create({
name: "General",
channel_type: "channel",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
});
await start();
await openDiscuss(channelId);
await insertText(".o-mail-Composer-input", "@John D");
await contains(".o-mail-Composer-suggestion strong", { text: "John Doe" });
await insertText(".o-mail-Composer-input", "a");
await contains(".o-mail-Composer-suggestion strong", { count: 0, text: "John D" });
// Simulate pressing backspace
const textarea = document.querySelector(".o-mail-Composer-input");
textarea.value = textarea.value.slice(0, -1);
await contains(".o-mail-Composer-suggestion strong", { text: "John Doe" });
});
test("[text composer] command suggestion are shown after deleting a character", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
const channelId = pyEnv["discuss.channel"].create({
name: "General",
channel_type: "channel",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
});
await start();
await openDiscuss(channelId);
await insertText(".o-mail-Composer-input", "/he");
await contains(".o-mail-Composer-suggestion strong", { text: "help" });
await insertText(".o-mail-Composer-input", "e");
await contains(".o-mail-Composer-suggestion strong", { count: 0, text: "help" });
// Simulate pressing backspace
const textarea = document.querySelector(".o-mail-Composer-input");
textarea.value = textarea.value.slice(0, -1);
await contains(".o-mail-Composer-suggestion strong", { text: "help" });
});
test.tags("html composer");
test("command suggestion are shown after deleting a character", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
const channelId = pyEnv["discuss.channel"].create({
name: "General",
channel_type: "channel",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
});
await start();
const composerService = getService("mail.composer");
composerService.setHtmlComposer();
await openDiscuss(channelId);
await contains(".o-mail-Composer-suggestionList");
await contains(".o-mail-Composer-suggestionList .o-open", { count: 0 });
await focus(".o-mail-Composer-html.odoo-editor-editable");
const editor = {
document,
editable: document.querySelector(".o-mail-Composer-html.odoo-editor-editable"),
};
await htmlInsertText(editor, "/he");
await contains(".o-mail-Composer-suggestion strong", { text: "help" });
await htmlInsertText(editor, "e");
await contains(".o-mail-Composer-suggestion strong", { count: 0, text: "help" });
await press("Backspace");
await contains(".o-mail-Composer-suggestion strong", { text: "help" });
});
test("mention suggestion displays OdooBot before archived partners", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Jane", active: false });
const channelId = pyEnv["discuss.channel"].create({
name: "Our channel",
channel_type: "group",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
Command.create({ partner_id: serverState.odoobotId }),
],
});
await start();
await openDiscuss(channelId);
await insertText(".o-mail-Composer-input", "@");
await contains(".o-mail-Composer-suggestion", { count: 3 });
await contains(".o-mail-Composer-suggestion", {
text: "Mitchell Admin",
before: [
".o-mail-Composer-suggestion",
{
text: "OdooBot",
before: [".o-mail-Composer-suggestion", { text: "Jane" }],
},
],
});
});

View file

@ -0,0 +1,175 @@
import {
click,
contains,
defineMailModels,
insertText,
openDiscuss,
start,
startServer,
triggerHotkey,
} from "@mail/../tests/mail_test_helpers";
import { describe, test } from "@odoo/hoot";
import { Command, serverState } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
defineMailModels();
test("can open DM from @username in command palette", async () => {
const pyEnv = await startServer();
const marioUid = pyEnv["res.users"].create({ name: "Mario" });
pyEnv["res.partner"].create({ name: "Mario", user_ids: [marioUid] });
await start();
triggerHotkey("control+k");
await insertText(".o_command_palette_search input", "@");
await insertText("input[placeholder='Search a conversation']", "Mario");
await click(".o_command.focused:has(.oi-user)", { text: "Mario" });
await contains(".o-mail-ChatWindow", { text: "Mario" });
});
test("can open channel from @channel_name in command palette", async () => {
const pyEnv = await startServer();
pyEnv["discuss.channel"].create({
name: "general",
channel_member_ids: [
Command.create({
partner_id: serverState.partnerId,
last_interest_dt: "2021-01-02 10:00:00", // same last interest to sort by id
}),
],
});
pyEnv["discuss.channel"].create({
name: "project",
channel_member_ids: [
Command.create({
partner_id: serverState.partnerId,
last_interest_dt: "2021-01-02 10:00:00", // same last interest to sort by id
}),
],
});
await start();
triggerHotkey("control+k");
await insertText(".o_command_palette_search input", "@");
await contains(".o_command", { count: 6 });
await contains(".o_command:eq(0):has(.fa-hashtag)", { text: "project" });
await contains(".o_command:eq(1):has(.fa-hashtag)", { text: "general" });
await contains(".o_command:has(.oi-user)", { text: "OdooBot" });
await contains(".o_command:has(.oi-user)", { text: "Mitchell Admin" }); // self-conversation
await contains(".o_command", { text: "Create Channel" });
await contains(".o_command", { text: "Create Chat" });
await click(".o_command.focused:has(.fa-hashtag)", { text: "project" });
await contains(".o-mail-ChatWindow", { text: "project" });
});
test("Conversation mentions in the command palette with @", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Mario" });
const channelId = pyEnv["discuss.channel"].create({
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
channel_type: "group",
});
const messageId = pyEnv["mail.message"].create({
author_id: partnerId,
model: "discuss.channel",
res_id: channelId,
body: "@Mitchell Admin",
needaction: true,
});
pyEnv["mail.notification"].create({
mail_message_id: messageId,
notification_type: "inbox",
res_partner_id: serverState.partnerId,
});
await start();
triggerHotkey("control+k");
await insertText(".o_command_palette_search input", "@", { replace: true });
await contains(".o_command_palette .o_command_category", {
contains: [
["span.fw-bold", { text: "Mentions" }],
[".o_command.focused .o_command_name", { text: "Mitchell Admin and Mario" }],
],
});
// can also make self conversation
await contains(".o_command_palette .o_command_category", {
contains: [[".o_command_name", { text: "Mitchell Admin" }]],
});
await click(".o_command.focused");
await contains(".o-mail-ChatWindow", { text: "Mitchell Admin and Mario" });
});
test("Max 3 most recent conversations in command palette of Discuss", async () => {
const pyEnv = await startServer();
pyEnv["discuss.channel"].create({ name: "channel_1" });
pyEnv["discuss.channel"].create({ name: "channel_2" });
pyEnv["discuss.channel"].create({ name: "channel_3" });
pyEnv["discuss.channel"].create({ name: "channel_4" });
await start();
triggerHotkey("control+k");
await insertText(".o_command_palette_search input", "@", { replace: true });
await contains(".o_command_palette .o_command_category", {
contains: [
["span.fw-bold", { text: "Recent" }],
[".o_command", { count: 3 }],
],
});
});
test("only partners with dedicated users will be displayed in command palette", async () => {
const pyEnv = await startServer();
const demoUid = pyEnv["res.users"].create({ name: "Demo" });
pyEnv["res.partner"].create({ name: "Demo", user_ids: [demoUid] });
pyEnv["res.partner"].create({ name: "Portal" });
await start();
triggerHotkey("control+k");
await insertText(".o_command_palette_search input", "@");
await contains(".o_command_name", { count: 5 });
await contains(".o_command_name", { text: "Demo" });
await contains(".o_command_name", { text: "OdooBot" });
await contains(".o_command_name", { text: "Mitchell Admin" }); // self-conversation
await contains(".o_command_name", { text: "Create Channel" });
await contains(".o_command_name", { text: "Create Chat" });
await contains(".o_command_name", { text: "Portal", count: 0 });
});
test("hide conversations in recent if they have mentions", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: serverState.odoobotId }),
],
channel_type: "chat",
});
pyEnv["mail.message"].create({
author_id: serverState.partnerId,
model: "discuss.channel",
res_id: channelId,
body: "@OdooBot",
});
await start();
triggerHotkey("control+k");
await insertText(".o_command_palette_search input", "@", { replace: true });
await contains(".o_command_category span.fw-bold", { text: "Mentions" });
await contains(".o_command_palette .o_command_category .o_command_name", {
text: "OdooBot",
count: 1,
});
});
test("Ctrl-K opens @ command palette in discuss app", async () => {
await start();
await openDiscuss();
triggerHotkey("control+k");
await contains(".o_command_palette_search", { text: "@" });
});
test("Can create group chat from ctrl-k without any user selected", async () => {
await start();
await openDiscuss();
triggerHotkey("control+k");
await click(".o_command_name:contains(Create Chat)");
await click(".modal-footer > .btn:contains(Create Group Chat)");
await contains(".o-mail-DiscussSidebarChannel-itemName", { text: "Mitchell Admin" });
});

View file

@ -0,0 +1,30 @@
import {
click,
contains,
defineMailModels,
openDiscuss,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { describe, test } from "@odoo/hoot";
import { asyncStep, mockService, waitForSteps } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
defineMailModels();
test("Channel subscription is renewed when channel is manually added", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General", channel_member_ids: [] });
await start();
mockService("bus_service", {
forceUpdateChannels() {
asyncStep("update-channels");
},
});
await openDiscuss(channelId);
await contains(".o-discuss-ChannelMemberList"); // wait for auto-open of this panel
await click("[title='Invite People']");
await click(".o-discuss-ChannelInvitation-selectable", { text: "Mitchell Admin" });
await click("[title='Invite']:enabled");
await waitForSteps(["update-channels"]);
});

View file

@ -0,0 +1,244 @@
import {
click,
contains,
defineMailModels,
insertText,
listenStoreFetch,
onRpcBefore,
openDiscuss,
patchUiSize,
SIZES,
start,
startServer,
STORE_FETCH_ROUTES,
triggerHotkey,
waitStoreFetch,
} from "@mail/../tests/mail_test_helpers";
import { describe, test } from "@odoo/hoot";
import {
asyncStep,
Command,
onRpc,
serverState,
waitForSteps,
} from "@web/../tests/web_test_helpers";
import { pick } from "@web/core/utils/objects";
describe.current.tags("desktop");
defineMailModels();
test("can create a new channel", async () => {
const pyEnv = await startServer();
onRpcBefore((route, args) => {
if (
(route.startsWith("/mail") || route.startsWith("/discuss")) &&
!STORE_FETCH_ROUTES.includes(route)
) {
asyncStep(`${route} - ${JSON.stringify(args)}`);
}
});
listenStoreFetch(undefined, { logParams: ["/discuss/create_channel"] });
await start();
await openDiscuss();
await waitStoreFetch([
"failures",
"systray_get_activities",
"init_messaging",
"channels_as_member",
]);
await contains(".o-mail-Discuss");
await contains(".o-mail-DiscussSidebar-item", { text: "abc", count: 0 });
await click("input[placeholder='Search conversations']");
await insertText("input[placeholder='Search a conversation']", "abc");
await waitForSteps([`/discuss/search - {"term":""}`, `/discuss/search - {"term":"abc"}`]);
await click("a", { text: "Create Channel" });
await contains(".o-mail-DiscussSidebar-item", { text: "abc" });
await contains(".o-mail-Message", { count: 0 });
const [channelId] = pyEnv["discuss.channel"].search([["name", "=", "abc"]]);
const [selfMember] = pyEnv["discuss.channel.member"].search_read([
["channel_id", "=", channelId],
["partner_id", "=", serverState.partnerId],
]);
await waitStoreFetch([["/discuss/create_channel", { name: "abc" }]], {
stepsAfter: [
`/discuss/channel/messages - ${JSON.stringify({
channel_id: channelId,
fetch_params: { limit: 60, around: selfMember.new_message_separator },
})}`,
`/discuss/channel/members - ${JSON.stringify({
channel_id: channelId,
known_member_ids: [selfMember.id],
})}`,
],
});
});
test("can make a DM chat", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Mario" });
pyEnv["res.users"].create({ partner_id: partnerId });
onRpcBefore((route, args) => {
if (
(route.startsWith("/mail") || route.startsWith("/discuss")) &&
!STORE_FETCH_ROUTES.includes(route)
) {
asyncStep(`${route} - ${JSON.stringify(args)}`);
}
});
onRpc((params) => {
if (params.model === "discuss.channel" && ["search_read"].includes(params.method)) {
asyncStep(
`${params.route} - ${JSON.stringify(
pick(params, "args", "kwargs", "method", "model")
)}`
);
}
});
listenStoreFetch(undefined, {
logParams: ["/discuss/get_or_create_chat"],
});
await start();
await waitStoreFetch(["failures", "systray_get_activities", "init_messaging"]);
await openDiscuss();
await waitStoreFetch(["channels_as_member"]);
await contains(".o-mail-Discuss");
await contains(".o-mail-DiscussSidebar-item", { text: "Mario", count: 0 });
await click("input[placeholder='Search conversations']");
await contains(".o_command_name", { count: 5 });
await insertText("input[placeholder='Search a conversation']", "mario");
await contains(".o_command_name", { count: 3 });
await click(".o_command_name", { text: "Mario" });
await contains(".o-mail-DiscussSidebar-item", { text: "Mario" });
await contains(".o-mail-Message", { count: 0 });
const [channelId] = pyEnv["discuss.channel"].search([["name", "=", "Mario, Mitchell Admin"]]);
await waitStoreFetch([["/discuss/get_or_create_chat", { partners_to: [partnerId] }]], {
stepsAfter: [
`/discuss/channel/messages - ${JSON.stringify({
channel_id: channelId,
fetch_params: { limit: 60, around: 0 },
})}`,
],
stepsBefore: [`/discuss/search - {"term":""}`, `/discuss/search - {"term":"mario"}`],
});
});
test("can create a group chat conversation", async () => {
const pyEnv = await startServer();
const [partnerId_1, partnerId_2] = pyEnv["res.partner"].create([
{ name: "Mario" },
{ name: "Luigi" },
]);
pyEnv["res.users"].create([{ partner_id: partnerId_1 }, { partner_id: partnerId_2 }]);
await start();
await openDiscuss();
await click("input[placeholder='Search conversations']");
await click("a", { text: "Create Chat" });
await click("li", { text: "Mario" });
await click("li", { text: "Luigi" });
await click(".btn", { text: "Create Group Chat" });
await contains(".o-mail-DiscussSidebarChannel");
await contains(".o-mail-Message", { count: 0 });
});
test("mobile chat search should allow to create group chat", async () => {
patchUiSize({ size: SIZES.SM });
await start();
await openDiscuss();
await contains("button.active", { text: "Notifications" });
await click("button", { text: "Chats" });
await contains(".o-mail-DiscussSearch-inputContainer");
});
test("Chat is pinned on other tabs when joined", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Jerry Golay" });
pyEnv["res.users"].create({ partner_id: partnerId });
const env1 = await start({ asTab: true });
const env2 = await start({ asTab: true });
await openDiscuss(undefined, { target: env1 });
await openDiscuss(undefined, { target: env2 });
await click(`${env1.selector} input[placeholder='Search conversations']`);
await contains(`${env1.selector} .o_command_name`, { count: 5 });
await insertText(`${env1.selector} input[placeholder='Search a conversation']`, "Jer");
await contains(`${env1.selector} .o_command_name`, { count: 3 });
await click(`${env1.selector} .o_command_name`, { text: "Jerry Golay" });
await contains(`${env1.selector} .o-mail-DiscussSidebar-item`, { text: "Jerry Golay" });
await contains(`${env2.selector} .o-mail-DiscussSidebar-item`, { text: "Jerry Golay" });
});
test("Auto-open OdooBot chat when opening discuss for the first time", async () => {
// Odoobot chat has onboarding for using Discuss app.
// We assume pinned odoobot chat without any message seen means user just started using Discuss app.
const pyEnv = await startServer();
pyEnv["discuss.channel"].create({
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: serverState.odoobotId }),
],
channel_type: "chat",
});
await start();
await openDiscuss();
await contains(".o-mail-DiscussContent-threadName", { value: "OdooBot" });
});
test("no conversation selected when opening non-existing channel in discuss", async () => {
await startServer();
await start();
await openDiscuss(200); // non-existing id
await contains("h4", { text: "No conversation selected." });
await contains(".o-mail-DiscussSidebarCategory-channel .oi-chevron-down");
await click(".o-mail-DiscussSidebar .btn", { text: "Channels" }); // check no crash
await contains(".o-mail-DiscussSidebarCategory-channel .oi-chevron-right");
});
test("can access portal partner profile from avatar popover", async () => {
const pyEnv = await startServer();
const joelPartnerId = pyEnv["res.partner"].create({
name: "Joel",
user_ids: [Command.create({ name: "Joel", share: true })],
});
const channelId = pyEnv["discuss.channel"].create({
name: "General",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: joelPartnerId }),
],
});
pyEnv["mail.message"].create({
author_id: joelPartnerId,
body: "Hello!",
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
});
await start();
await openDiscuss(channelId);
await click(".o-mail-Message-avatar", {
parent: [".o-mail-Message", { text: "Joel" }],
});
await contains(".o_avatar_card", { text: "Joel" });
await click("button", { text: "View Profile" });
await contains(".o_form_view");
await contains(".o_field_widget[name='name'] .o_input", { value: "Joel" });
});
test("Preserve letter case and accents when creating channel from sidebar", async () => {
await start();
await openDiscuss();
await click("input[placeholder='Search conversations']");
await insertText("input[placeholder='Search a conversation']", "Crème brûlée Fan Club");
await click("a", { text: "Create Channel" });
await contains(".o-mail-DiscussContent-threadName", { value: "Crème brûlée Fan Club" });
});
test("Create channel must have a name", async () => {
await start();
await openDiscuss();
await click("input[placeholder='Search conversations']");
await click("a", { text: "Create Channel" });
await click("input[placeholder='Channel name']");
await triggerHotkey("Enter");
await contains(".invalid-feedback", { text: "Channel must have a name." });
});

View file

@ -0,0 +1,334 @@
import {
SIZES,
click,
contains,
defineMailModels,
insertText,
openDiscuss,
patchUiSize,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { describe, disableAnimations, expect, mockPermission, mockTouch, test } from "@odoo/hoot";
import {
Command,
contains as webContains,
getService,
serverState,
swipeLeft,
swipeRight,
withUser,
} from "@web/../tests/web_test_helpers";
import { rpc } from "@web/core/network/rpc";
describe.current.tags("desktop");
defineMailModels();
test("can make DM chat in mobile", async () => {
patchUiSize({ size: SIZES.SM });
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Gandalf" });
pyEnv["res.users"].create({ partner_id: partnerId });
await start();
await openDiscuss();
await contains("button.active", { text: "Notifications" });
await click("button", { text: "Chats" });
await click(".o-mail-DiscussSearch-inputContainer");
await contains(".o_command_name", { count: 5 });
await insertText("input[placeholder='Search a conversation']", "Gandalf");
await contains(".o_command_name", { count: 3 });
await click(".o_command_name", { text: "Gandalf" });
await contains(".o-mail-ChatWindow", { text: "Gandalf" });
});
test("can search channel in mobile", async () => {
patchUiSize({ size: SIZES.SM });
const pyEnv = await startServer();
pyEnv["discuss.channel"].create({ name: "Gryffindors" });
await start();
await openDiscuss();
await contains("button.active", { text: "Notifications" });
await click("button", { text: "Channels" });
await click(".o-mail-DiscussSearch-inputContainer");
await contains(".o_command_name", { count: 5 });
await insertText("input[placeholder='Search a conversation']", "Gryff");
await contains(".o_command_name", { count: 3 });
await click(".o_command_name", { text: "Gryffindors" });
await contains(".o-mail-ChatWindow div[title='Gryffindors']");
});
test("can make new channel in mobile", async () => {
patchUiSize({ size: SIZES.SM });
await start();
await openDiscuss();
await contains("button.active", { text: "Notifications" });
await click("button", { text: "Channels" });
await click(".o-mail-DiscussSearch-inputContainer");
await insertText("input[placeholder='Search a conversation']", "slytherins");
await click("a", { text: "Create Channel" });
await contains(".o-mail-ChatWindow", { text: "slytherins" });
});
test("new message opens the @ command palette", async () => {
await start();
await click(".o_menu_systray .dropdown-toggle i[aria-label='Messages']");
await click(".o-mail-MessagingMenu button", { text: "New Message" });
await contains(".o_command_palette_search .o_namespace", { text: "@" });
await contains(".o_command_palette input[placeholder='Search a conversation']");
});
test("channel preview show deleted messages", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Demo" });
const channelId = pyEnv["discuss.channel"].create({
name: "General",
});
pyEnv["mail.message"].create({
author_id: partnerId,
body: "<p>before last</p>",
model: "discuss.channel",
res_id: channelId,
});
pyEnv["mail.message"].create({
author_id: partnerId,
body: "<p></p>",
model: "discuss.channel",
res_id: channelId,
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-Message", { text: "before last" });
await click(".o_menu_systray .dropdown-toggle:has(i[aria-label='Messages'])");
await contains(".o-mail-NotificationItem-text", {
text: "Demo: This message has been removed",
});
});
test("deleted message should not show parent message reference and mentions", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
const messageId = pyEnv["mail.message"].create({
body: "<p>Parent Message</p>",
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
});
pyEnv["mail.message"].create({
body: "<p>reply message</p>",
message_type: "comment",
model: "discuss.channel",
parent_id: messageId,
partner_ids: [serverState.partnerId],
res_id: channelId,
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-MessageInReply", { text: "Parent Message" });
await webContains(
".o-mail-Message:has(.o-mail-Message-bubble.o-orange):contains('reply message')"
).hover();
await webContains(
".o-mail-Message:has(.o-mail-Message-bubble.o-orange):contains('reply message') [title='Expand']"
).click();
await click(".o-mail-Message-moreMenu .o-dropdown-item:contains(Delete)");
await click(".o_dialog button:contains(Delete)");
await contains(".o-mail-Message:not(:has(.o-mail-Message-bubble.o-orange))", {
text: "This message has been removed",
});
await contains(".o-mail-MessageInReply", { count: 0 });
});
test("channel preview ignores transient message", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Demo" });
const channelId = pyEnv["discuss.channel"].create({
name: "General",
});
pyEnv["mail.message"].create({
author_id: partnerId,
body: "<p>test</p>",
model: "discuss.channel",
res_id: channelId,
});
await start();
await openDiscuss(channelId);
await insertText(".o-mail-Composer-input", "/who");
await click(".o-mail-Composer button[title='Send']:enabled");
await contains(".o_mail_notification", { text: "You are alone in this channel." });
await click(".o_menu_systray .dropdown-toggle:has(i[aria-label='Messages'])");
await contains(".o-mail-NotificationItem-text", { text: "Demo: test" });
});
test("channel preview ignores messages from the past", async () => {
disableAnimations();
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
const messageId = pyEnv["mail.message"].create({
body: "first message",
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
});
for (let i = 0; i < 100; i++) {
pyEnv["mail.message"].create({
body: `message ${i}`,
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
});
}
const newestMessageId = pyEnv["mail.message"].create({
body: "last message",
message_type: "comment",
model: "discuss.channel",
parent_id: messageId,
res_id: channelId,
});
const [selfMember] = pyEnv["discuss.channel.member"].search_read([
["partner_id", "=", serverState.partnerId],
["channel_id", "=", channelId],
]);
pyEnv["discuss.channel.member"].write([selfMember.id], {
new_message_separator: newestMessageId + 1,
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-Message", { count: 30 });
await contains(".o-mail-Message-content", { text: "last message" });
await contains(".o-mail-Thread", { scroll: "bottom" });
await click(".o-mail-MessageInReply-content", { text: "first message" });
await contains(".o-mail-Message", { count: 31 });
await contains(".o-mail-Message-content", { text: "first message" });
await contains(".o-mail-Message-content", { text: "last message", count: 0 });
await click(".o_menu_systray .dropdown-toggle:has(i[aria-label='Messages'])");
await contains(".o-mail-NotificationItem-text", { text: "You: last message" });
withUser(serverState.userId, () =>
rpc("/mail/message/post", {
post_data: { body: "it's a good idea", message_type: "comment" },
thread_id: channelId,
thread_model: "discuss.channel",
})
);
await contains(".o-mail-NotificationItem-text", { text: "You: it's a good idea" });
});
test("counter is taking into account non-fetched channels", async () => {
mockPermission("notifications", "denied");
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Jane" });
const channelId = pyEnv["discuss.channel"].create({
name: "General",
channel_member_ids: [
Command.create({ message_unread_counter: 1, partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
});
pyEnv["mail.message"].create({
author_id: partnerId,
body: "first message",
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
});
await start();
await contains(".o-mail-MessagingMenu-counter", { text: "1" });
expect(
Boolean(getService("mail.store").Thread.get({ model: "discuss.channel", id: channelId }))
).toBe(false);
});
test("counter is updated on receiving message on non-fetched channels", async () => {
mockPermission("notifications", "denied");
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Jane" });
const userId = pyEnv["res.users"].create({ partner_id: partnerId });
const channelId = pyEnv["discuss.channel"].create({
name: "General",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
});
pyEnv["mail.message"].create({
author_id: partnerId,
body: "first message",
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
});
await start();
await contains(".o_menu_systray .dropdown-toggle i[aria-label='Messages']");
await contains(".o-mail-MessagingMenu-counter", { count: 0 });
expect(
Boolean(getService("mail.store").Thread.get({ model: "discuss.channel", id: channelId }))
).toBe(false);
withUser(userId, () =>
rpc("/mail/message/post", {
post_data: { body: "good to know", message_type: "comment" },
thread_id: channelId,
thread_model: "discuss.channel",
})
);
await contains(".o-mail-MessagingMenu-counter", { text: "1" });
});
test("can use notification item swipe actions", async () => {
mockTouch(true);
patchUiSize({ size: SIZES.SM });
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Demo", email: "demo@odoo.com" });
const channelId = pyEnv["discuss.channel"].create({
channel_type: "chat",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
});
pyEnv["mail.message"].create({
author_id: partnerId,
body: "A message",
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
});
await start();
await openDiscuss();
await contains("button.active", { text: "Notifications" });
await click("button", { text: "Chats" });
await contains(".o-mail-NotificationItem .o-mail-NotificationItem-badge:contains(1)");
await swipeRight(".o_actionswiper"); // marks as read
await contains(".o-mail-NotificationItem-badge", { count: 0 });
await swipeLeft(".o_actionswiper"); // unpins
await contains(".o-mail-NotificationItem", { count: 0 });
});
test("counter does not double count channel needaction messages", async () => {
const pyEnv = await startServer();
pyEnv["res.users"].write(serverState.userId, { notification_type: "inbox" });
const partnerId = pyEnv["res.partner"].create({ name: "Jane" });
const channelId = pyEnv["discuss.channel"].create({
name: "General",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
});
const messageId = pyEnv["mail.message"].create({
author_id: partnerId,
body: "Hey @Mitchell Admin",
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
});
pyEnv["mail.notification"].create({
mail_message_id: messageId,
notification_status: "sent",
notification_type: "inbox",
res_partner_id: serverState.partnerId,
});
await start();
await click(".o_menu_systray i[aria-label='Messages']"); // fetch channels
await contains(".o-mail-NotificationItem", { text: "General" }); // ensure channels fetched
await contains(".o-mail-MessagingMenu-counter:text('1')");
});

View file

@ -0,0 +1,47 @@
import { waitForChannels } from "@bus/../tests/bus_test_helpers";
import {
click,
contains,
defineMailModels,
insertText,
openDiscuss,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { describe, test } from "@odoo/hoot";
import { press } from "@odoo/hoot-dom";
import { asyncStep, Command, serverState, waitForSteps } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
defineMailModels();
test("unknown channel can be displayed and interacted with", async () => {
const pyEnv = await startServer();
pyEnv["res.users"].write(serverState.userId, { notification_type: "inbox" });
const partnerId = pyEnv["res.partner"].create({ name: "Jane" });
const channelId = pyEnv["discuss.channel"].create({
channel_member_ids: [Command.create({ partner_id: partnerId })],
channel_type: "channel",
name: "Not So Secret",
});
const env = await start();
env.services.bus_service.subscribe("discuss.channel/new_message", () =>
asyncStep("discuss.channel/new_message")
);
await openDiscuss("mail.box_inbox");
await contains("button.o-active", { text: "Inbox" });
await contains(".o-mail-DiscussSidebarChannel", { count: 0 });
await openDiscuss(channelId);
await waitForChannels([`discuss.channel_${channelId}`]);
await contains(".o-mail-DiscussSidebarChannel.o-active", { text: "Not So Secret" });
await insertText(".o-mail-Composer-input", "Hello", { replace: true });
await press("Enter");
await contains(".o-mail-Message", { text: "Hello" });
await waitForSteps(["discuss.channel/new_message"]);
await click("button", { text: "Inbox" });
await contains(".o-mail-DiscussSidebarChannel:not(.o-active)", { text: "Not So Secret" });
await click("[title='Channel Actions']");
await click(".o-dropdown-item:contains('Leave Channel')");
await click("button", { text: "Leave Conversation" });
await contains(".o-mail-DiscussSidebarChannel", { count: 0 });
});

View file

@ -0,0 +1,145 @@
import { AWAY_DELAY } from "@mail/core/common/im_status_service";
import { defineMailModels, start, startServer } from "@mail/../tests/mail_test_helpers";
import { beforeEach, describe, expect, test } from "@odoo/hoot";
import { advanceTime, freezeTime } from "@odoo/hoot-dom";
import { registry } from "@web/core/registry";
import {
asyncStep,
makeMockEnv,
mockService,
patchWithCleanup,
restoreRegistry,
serverState,
waitForSteps,
} from "@web/../tests/web_test_helpers";
defineMailModels();
beforeEach(freezeTime);
describe.current.tags("headless");
test("update presence if IM status changes to offline while this device is online", async () => {
mockService("bus_service", { send: (type) => asyncStep(type) });
const pyEnv = await startServer();
pyEnv["res.partner"].write(serverState.partnerId, { im_status: "online" });
await start();
await waitForSteps(["update_presence"]);
pyEnv["bus.bus"]._sendone(serverState.partnerId, "bus.bus/im_status_updated", {
presence_status: "offline",
im_status: "offline",
partner_id: serverState.partnerId,
});
await waitForSteps(["update_presence"]);
});
test("update presence if IM status changes to away while this device is online", async () => {
mockService("bus_service", { send: (type) => asyncStep(type) });
localStorage.setItem("presence.lastPresence", Date.now());
const pyEnv = await startServer();
pyEnv["res.partner"].write(serverState.partnerId, { im_status: "online" });
await start();
await waitForSteps(["update_presence"]);
pyEnv["bus.bus"]._sendone(serverState.partnerId, "bus.bus/im_status_updated", {
presence_status: "away",
im_status: "away",
partner_id: serverState.partnerId,
});
await waitForSteps(["update_presence"]);
});
test("do not update presence if IM status changes to away while this device is away", async () => {
mockService("bus_service", { send: (type) => asyncStep(type) });
localStorage.setItem("presence.lastPresence", Date.now() - AWAY_DELAY);
const pyEnv = await startServer();
pyEnv["res.partner"].write(serverState.partnerId, { im_status: "away" });
await start();
await waitForSteps(["update_presence"]);
pyEnv["bus.bus"]._sendone(serverState.partnerId, "bus.bus/im_status_updated", {
presence_status: "away",
im_status: "away",
partner_id: serverState.partnerId,
});
await waitForSteps([]);
});
test("do not update presence if other user's IM status changes to away", async () => {
mockService("bus_service", { send: (type) => asyncStep(type) });
localStorage.setItem("presence.lastPresence", Date.now());
const pyEnv = await startServer();
pyEnv["res.partner"].write(serverState.partnerId, { im_status: "online" });
await start();
await waitForSteps(["update_presence"]);
pyEnv["bus.bus"]._sendone(serverState.partnerId, "bus.bus/im_status_updated", {
presence_status: "away",
im_status: "away",
partner_id: serverState.publicPartnerId,
});
await waitForSteps([]);
});
test("update presence when user comes back from away", async () => {
mockService("bus_service", {
send: (type, payload) => {
if (type === "update_presence") {
asyncStep(payload.inactivity_period);
}
},
});
localStorage.setItem("presence.lastPresence", Date.now() - AWAY_DELAY);
const pyEnv = await startServer();
pyEnv["res.partner"].write(serverState.partnerId, { im_status: "away" });
await start();
await waitForSteps([AWAY_DELAY]);
localStorage.setItem("presence.lastPresence", Date.now());
await waitForSteps([0]);
});
test("update presence when user status changes to away", async () => {
mockService("bus_service", {
send: (type, payload) => {
if (type === "update_presence") {
asyncStep(payload.inactivity_period);
}
},
});
localStorage.setItem("presence.lastPresence", Date.now());
const pyEnv = await startServer();
pyEnv["res.partner"].write(serverState.partnerId, { im_status: "online" });
await start();
await waitForSteps([0]);
await advanceTime(AWAY_DELAY);
await waitForSteps([AWAY_DELAY]);
});
test("new tab update presence when user comes back from away", async () => {
// Tabs notify presence with a debounced update, and the status service skips
// duplicates. This test ensures a new tab that never sent presence still issues
// its first update (important when old tabs close and new ones replace them).
localStorage.setItem("presence.lastPresence", Date.now() - AWAY_DELAY);
const pyEnv = await startServer();
pyEnv["res.partner"].write([serverState.partnerId], { im_status: "offline" });
const tabEnv_1 = await makeMockEnv();
patchWithCleanup(tabEnv_1.services.bus_service, {
send: (type) => {
if (type === "update_presence") {
expect.step("update_presence");
}
},
});
tabEnv_1.services.bus_service.start();
await expect.waitForSteps(["update_presence"]);
restoreRegistry(registry);
const tabEnv_2 = await makeMockEnv(null, { makeNew: true });
patchWithCleanup(tabEnv_2.services.bus_service, {
send: (type) => {
if (type === "update_presence") {
expect.step("update_presence");
}
},
});
tabEnv_2.services.bus_service.start();
await expect.waitForSteps([]);
localStorage.setItem("presence.lastPresence", Date.now()); // Simulate user presence.
await expect.waitForSteps(["update_presence", "update_presence"]);
});

View file

@ -0,0 +1,191 @@
import {
click,
contains,
defineMailModels,
openDiscuss,
scroll,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { describe, test, expect } from "@odoo/hoot";
import { disableAnimations } from "@odoo/hoot-mock";
describe.current.tags("desktop");
defineMailModels();
async function assertPinnedPanelUnpinCount(expectedCount) {
await contains(".dropdown-item", { text: "Unpin", count: expectedCount });
await click(".o-mail-DiscussContent-header button[title='Pinned Messages']");
await contains(".o-discuss-PinnedMessagesPanel .o-mail-Message", {
text: "Test pinned message",
});
expect(".o-discuss-PinnedMessagesPanel button[title='Unpin']").toHaveCount(expectedCount);
}
test("Pin message", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
pyEnv["mail.message"].create({
body: "Hello world!",
model: "discuss.channel",
res_id: channelId,
});
await start();
await openDiscuss(channelId);
await contains(".o-discuss-ChannelMemberList"); // wait for auto-open of this panel
await click(".o-mail-DiscussContent-header button[title='Pinned Messages']");
await contains(".o-discuss-PinnedMessagesPanel p", {
text: "This channel doesn't have any pinned messages.",
});
await click(".o-mail-Message [title='Expand']");
await click(".dropdown-item", { text: "Pin" });
await click(".modal-footer button", { text: "Yeah, pin it!" });
await contains(".o-discuss-PinnedMessagesPanel .o-mail-Message", { text: "Hello world!" });
});
test("Unpin message", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
pyEnv["mail.message"].create({
body: "Hello world!",
model: "discuss.channel",
res_id: channelId,
pinned_at: "2023-03-30 11:27:11",
});
await start();
await openDiscuss(channelId);
await contains(".o-discuss-ChannelMemberList"); // wait for auto-open of this panel
await click(".o-mail-DiscussContent-header button[title='Pinned Messages']");
await contains(".o-discuss-PinnedMessagesPanel .o-mail-Message");
await click(".o-mail-Message [title='Expand']");
await click(".dropdown-item", { text: "Unpin" });
await click(".modal-footer button", { text: "Yes, remove it please" });
await contains(".o-discuss-PinnedMessagesPanel .o-mail-Message", { count: 0 });
});
test("Open pinned panel from notification", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
pyEnv["mail.message"].create({
body: "Hello world!",
model: "discuss.channel",
res_id: channelId,
});
await start();
await openDiscuss(channelId);
await click(":nth-child(1 of .o-mail-Message) [title='Expand']");
await click(".dropdown-item", { text: "Pin" });
await click(".modal-footer button", { text: "Yeah, pin it!" });
await contains(".o-discuss-PinnedMessagesPanel", { count: 0 });
await click(".o_mail_notification a", { text: "See all pinned messages" });
await contains(".o-discuss-PinnedMessagesPanel");
});
test("Jump to message", async () => {
disableAnimations();
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
pyEnv["mail.message"].create({
body: "Hello world!",
model: "discuss.channel",
res_id: channelId,
pinned_at: "2023-04-03 08:15:04",
});
for (let i = 0; i < 20; i++) {
pyEnv["mail.message"].create({
body: "Non Empty Body ".repeat(25),
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
});
}
await start();
await openDiscuss(channelId);
await contains(".o-discuss-ChannelMemberList"); // wait for auto-open of this panel
await click(".o-mail-DiscussContent-header button[title='Pinned Messages']");
await click(".o-discuss-PinnedMessagesPanel a[role='button']", { text: "Jump" });
await contains(".o-mail-Thread .o-mail-Message-body", { text: "Hello world!", visible: true });
});
test("Jump to message from notification", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
pyEnv["mail.message"].create({
body: "Hello world!",
model: "discuss.channel",
res_id: channelId,
});
for (let i = 0; i < 20; i++) {
pyEnv["mail.message"].create({
body: "Non Empty Body ".repeat(25),
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
});
}
await start();
await openDiscuss(channelId);
await contains(".o-mail-Message", { count: 21 });
await click(":nth-child(1 of .o-mail-Message) [title='Expand']");
await click(".dropdown-item", { text: "Pin" });
await click(".modal-footer button", { text: "Yeah, pin it!" });
await contains(".o_mail_notification");
await scroll(".o-mail-Thread", "bottom");
await contains(".o-mail-Thread", { scroll: "bottom" });
await click(".o_mail_notification a", { text: "message" });
await contains(".o-mail-Thread", { count: 0, scroll: "bottom" });
});
test("can add reactions from pinned panel", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
pyEnv["mail.message"].create({
body: "Hello world!",
model: "discuss.channel",
res_id: channelId,
pinned_at: "2025-10-09 11:15:04",
});
await start();
await openDiscuss(channelId);
await click(".o-mail-Message-actions [title='Add a Reaction']");
await click(".o-mail-QuickReactionMenu button", { text: "👍" });
await contains(".o-mail-MessageReaction", { text: "👍1" });
await click(".o-mail-DiscussContent-header button[title='Pinned Messages']");
await click(".o-discuss-PinnedMessagesPanel .o-mail-Message [title='Add a Reaction']");
await click(".o-mail-QuickReactionMenu button", { text: "👍" });
await contains(".o-mail-MessageReaction", { count: 0 });
});
test("Guest user cannot see unpin button", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({
name: "General",
channel_type: "channel",
});
pyEnv["mail.message"].create({
body: "Test pinned message",
model: "discuss.channel",
res_id: channelId,
pinned_at: "2023-03-30 11:27:11",
});
await start({ authenticateAs: false });
await openDiscuss(channelId);
await contains(".o-mail-Message", { text: "Test pinned message" });
expect(".o-mail-Message [title='Expand']").toHaveCount(0);
await assertPinnedPanelUnpinCount(0);
});
test("Internal user can see unpin button", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
pyEnv["mail.message"].create({
body: "Test pinned message",
model: "discuss.channel",
res_id: channelId,
pinned_at: "2023-03-30 11:27:11",
});
await start();
await openDiscuss(channelId);
await click(".o-mail-Message [title='Expand']");
await assertPinnedPanelUnpinCount(1);
});

View file

@ -0,0 +1,348 @@
import {
click,
contains,
defineMailModels,
insertText,
onRpcBefore,
openDiscuss,
patchUiSize,
scroll,
SIZES,
start,
startServer,
triggerHotkey,
} from "@mail/../tests/mail_test_helpers";
import { expect, mockTouch, mockUserAgent, test } from "@odoo/hoot";
import { press } from "@odoo/hoot-dom";
import { tick } from "@odoo/hoot-mock";
import { serverState } from "@web/../tests/web_test_helpers";
import { HIGHLIGHT_CLASS } from "@mail/core/common/message_search_hook";
defineMailModels();
test.tags("desktop");
test("Should have a search button", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
await start();
await openDiscuss(channelId);
await contains("[title='Search Messages']");
});
test.tags("desktop");
test("Should open the search panel when search button is clicked", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
await start();
await openDiscuss(channelId);
await contains(".o-discuss-ChannelMemberList"); // wait for auto-open of this panel
await click("[title='Search Messages']");
await contains(".o-mail-SearchMessagesPanel");
await contains(".o_searchview");
await contains(".o_searchview_input");
});
test.tags("desktop");
test("Should open the search panel with hotkey 'f'", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
pyEnv["mail.message"].create({
author_id: serverState.partnerId,
body: "This is a message",
attachment_ids: [],
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-Message");
await press("alt+f");
await contains(".o-mail-SearchMessagesPanel");
});
test.tags("desktop");
test("Search a message", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
pyEnv["mail.message"].create({
author_id: serverState.partnerId,
body: "This is a message",
attachment_ids: [],
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-Message");
await click("button[title='Search Messages']");
await insertText(".o_searchview_input", "message");
triggerHotkey("Enter");
await contains(".o-mail-SearchMessagesPanel .o-mail-Message");
});
test.tags("desktop");
test("Search should be hightlighted", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
pyEnv["mail.message"].create({
author_id: serverState.partnerId,
body: "This is a message",
attachment_ids: [],
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-Message");
await click("[title='Search Messages']");
await insertText(".o_searchview_input", "message");
triggerHotkey("Enter");
await contains(`.o-mail-SearchMessagesPanel .o-mail-Message .${HIGHLIGHT_CLASS}`);
});
test.tags("desktop");
test("Search a starred message", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
pyEnv["mail.message"].create({
author_id: serverState.partnerId,
body: "This is a message",
attachment_ids: [],
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
starred_partner_ids: [serverState.partnerId],
});
await start();
await openDiscuss("mail.box_starred");
await contains(".o-mail-Message");
await click("[title='Search Messages']");
await insertText(".o_searchview_input", "message");
triggerHotkey("Enter");
await contains(".o-mail-SearchMessagesPanel .o-mail-Message");
});
test.tags("desktop");
test("Search a message in inbox", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
pyEnv["mail.message"].create({
author_id: serverState.partnerId,
body: "This is a message",
attachment_ids: [],
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
needaction: true,
});
await start();
await openDiscuss("mail.box_inbox");
await contains(".o-mail-Message");
await click("[title='Search Messages']");
await insertText(".o_searchview_input", "message");
triggerHotkey("Enter");
await contains(".o-mail-SearchMessagesPanel .o-mail-Message");
});
test.tags("desktop");
test("Search a message in history (desktop)", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
const messageId = pyEnv["mail.message"].create({
author_id: serverState.partnerId,
body: "This is a message",
attachment_ids: [],
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
needaction: false,
});
pyEnv["mail.notification"].create({
is_read: true,
mail_message_id: messageId,
notification_status: "sent",
notification_type: "inbox",
res_partner_id: serverState.partnerId,
});
await start();
await openDiscuss("mail.box_history");
await click("[title='Search Messages']");
await insertText(".o_searchview_input", "message");
triggerHotkey("Enter");
await contains(".o-mail-SearchMessagesPanel .o-mail-Message");
await click(".o-mail-SearchMessagesPanel .o-mail-MessageCard-jump");
await contains(".o-mail-Thread .o-mail-Message.o-highlighted");
});
test.tags("mobile");
test("Search a message in history (mobile)", async () => {
mockTouch(true);
mockUserAgent("android");
patchUiSize({ size: SIZES.SM });
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
const messageId = pyEnv["mail.message"].create({
author_id: serverState.partnerId,
body: "This is a message",
attachment_ids: [],
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
needaction: false,
});
pyEnv["mail.notification"].create({
is_read: true,
mail_message_id: messageId,
notification_status: "sent",
notification_type: "inbox",
res_partner_id: serverState.partnerId,
});
await start();
await openDiscuss("mail.box_history");
await contains(".o-mail-Thread");
await click("[title='Search Messages']");
await contains(".o-mail-SearchMessagesPanel");
await contains(".o-mail-Thread", { count: 0 });
await insertText(".o_searchview_input", "message");
await triggerHotkey("Enter");
await contains(".o-mail-SearchMessagesPanel .o-mail-Message");
await click(".o-mail-MessageCard-jump");
await contains(".o-mail-Thread");
await contains(".o-mail-SearchMessagesPanel", { count: 0 });
});
test.tags("desktop");
test("Should close the search panel when search button is clicked again", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
await start();
await openDiscuss(channelId);
await contains(".o-discuss-ChannelMemberList"); // wait for auto-open of this panel
await click("[title='Search Messages']");
await click("[title='Close Search']");
await contains(".o-mail-SearchMessagesPanel");
});
test.tags("desktop");
test("Search a message in 60 messages should return 30 message first", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
for (let i = 0; i < 60; i++) {
pyEnv["mail.message"].create({
author_id: serverState.partnerId,
body: "This is a message",
attachment_ids: [],
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
});
}
await start();
await openDiscuss(channelId);
await contains(".o-mail-Message", { count: 30 });
await click("[title='Search Messages']");
await insertText(".o_searchview_input", "message");
triggerHotkey("Enter");
await contains(".o-mail-SearchMessagesPanel .o-mail-Message", { count: 30 });
// give enough time to useVisible to potentially load more (unexpected) messages
await tick();
await contains(".o-mail-SearchMessagesPanel .o-mail-Message", { count: 30 });
});
test.tags("desktop");
test("Scrolling to the bottom should load more searched message", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
for (let i = 0; i < 90; i++) {
pyEnv["mail.message"].create({
author_id: serverState.partnerId,
body: "This is a message",
attachment_ids: [],
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
});
}
await start();
await openDiscuss(channelId);
await contains(".o-mail-Message", { count: 30 });
await click("[title='Search Messages']");
await insertText(".o_searchview_input", "message");
triggerHotkey("Enter");
await contains(".o-mail-SearchMessagesPanel .o-mail-Message", { count: 30 });
await scroll(".o-mail-SearchMessagesPanel .o-mail-ActionPanel", "bottom");
await contains(".o-mail-SearchMessagesPanel .o-mail-Message", { count: 60 });
// give enough time to useVisible to potentially load more (unexpected) messages
await tick();
await contains(".o-mail-SearchMessagesPanel .o-mail-Message", { count: 60 });
});
test.tags("desktop");
test("Editing the searched term should not edit the current searched term", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
for (let i = 0; i < 60; i++) {
pyEnv["mail.message"].create({
author_id: serverState.partnerId,
body: "This is a message",
attachment_ids: [],
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
});
}
onRpcBefore("/discuss/channel/messages", (args) => {
if (args.search_term) {
const { search_term } = args;
expect(search_term).toBe("message");
}
});
await start();
await openDiscuss(channelId);
await contains(".o-discuss-ChannelMemberList"); // wait for auto-open of this panel
await click("[title='Search Messages']");
await insertText(".o_searchview_input", "message");
triggerHotkey("Enter");
await insertText(".o_searchview_input", "test");
await scroll(".o-mail-SearchMessagesPanel .o-mail-ActionPanel", "bottom");
});
test.tags("desktop");
test("Search a message containing round brackets", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
pyEnv["mail.message"].create({
author_id: serverState.partnerId,
body: "This is a (message)",
attachment_ids: [],
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-Message");
await click("button[title='Search Messages']");
await insertText(".o_searchview_input", "(message");
triggerHotkey("Enter");
await contains(".o-mail-SearchMessagesPanel .o-mail-Message");
});
test.tags("desktop");
test("Search a message containing single quotes", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
await start();
await openDiscuss(channelId);
await insertText(".o-mail-Composer-input", "I can't do it");
await click(".o-sendMessageActive:enabled");
await contains(".o-mail-Message");
await click("button[title='Search Messages']");
await insertText(".o_searchview_input", "can't");
triggerHotkey("Enter");
await contains(".o-mail-SearchMessagesPanel .o-mail-Message");
});

View file

@ -0,0 +1,906 @@
import { insertText as htmlInsertText } from "@html_editor/../tests/_helpers/user_actions";
import {
click,
contains,
defineMailModels,
insertText,
onRpcBefore,
openDiscuss,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { describe, test } from "@odoo/hoot";
import { advanceTime, mockDate } from "@odoo/hoot-mock";
import {
asyncStep,
Command,
getService,
serverState,
waitForSteps,
withUser,
} from "@web/../tests/web_test_helpers";
import { Store } from "@mail/core/common/store_service";
import { LONG_TYPING, SHORT_TYPING } from "@mail/discuss/typing/common/composer_patch";
import { rpc } from "@web/core/network/rpc";
describe.current.tags("desktop");
defineMailModels();
test('[text composer] receive other member typing status "is typing"', async () => {
const pyEnv = await startServer();
const userId = pyEnv["res.users"].create({ name: "Demo" });
const partnerId = pyEnv["res.partner"].create({ name: "Demo", user_ids: [userId] });
const channelId = pyEnv["discuss.channel"].create({
name: "channel",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
});
await start();
await openDiscuss(channelId);
await contains(".o-discuss-Typing");
await contains(".o-discuss-Typing", { count: 0, text: "Demo is typing...)" });
// simulate receive typing notification from demo
withUser(userId, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: channelId,
is_typing: true,
})
);
await contains(".o-discuss-Typing", { text: "Demo is typing..." });
});
test.tags("html composer");
test('receive other member typing status "is typing"', async () => {
const pyEnv = await startServer();
const userId = pyEnv["res.users"].create({ name: "Demo" });
const partnerId = pyEnv["res.partner"].create({ name: "Demo", user_ids: [userId] });
const channelId = pyEnv["discuss.channel"].create({
name: "channel",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
});
await start();
const composerService = getService("mail.composer");
composerService.setHtmlComposer();
await openDiscuss(channelId);
await contains(".o-discuss-Typing");
await contains(".o-discuss-Typing", { count: 0, text: "Demo is typing...)" });
withUser(userId, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: channelId,
is_typing: true,
})
);
await contains(".o-discuss-Typing", { text: "Demo is typing..." });
});
test('[text composer] receive other member typing status "is typing" then "no longer is typing"', async () => {
const pyEnv = await startServer();
const userId = pyEnv["res.users"].create({ name: "Demo" });
const partnerId = pyEnv["res.partner"].create({ name: "Demo", user_ids: [userId] });
const channelId = pyEnv["discuss.channel"].create({
name: "channel",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
});
await start();
await openDiscuss(channelId);
await contains(".o-discuss-Typing");
await contains(".o-discuss-Typing", { count: 0, text: "Demo is typing...)" });
// simulate receive typing notification from demo "is typing"
withUser(userId, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: channelId,
is_typing: true,
})
);
await contains(".o-discuss-Typing", { text: "Demo is typing..." });
// simulate receive typing notification from demo "is no longer typing"
withUser(userId, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: channelId,
is_typing: false,
})
);
await contains(".o-discuss-Typing");
await contains(".o-discuss-Typing", { count: 0, text: "Demo is typing...)" });
});
test.tags("html composer");
test('receive other member typing status "is typing" then "no longer is typing"', async () => {
const pyEnv = await startServer();
const userId = pyEnv["res.users"].create({ name: "Demo" });
const partnerId = pyEnv["res.partner"].create({ name: "Demo", user_ids: [userId] });
const channelId = pyEnv["discuss.channel"].create({
name: "channel",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
});
await start();
const composerService = getService("mail.composer");
composerService.setHtmlComposer();
await openDiscuss(channelId);
await contains(".o-discuss-Typing");
await contains(".o-discuss-Typing", { count: 0, text: "Demo is typing...)" });
withUser(userId, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: channelId,
is_typing: true,
})
);
await contains(".o-discuss-Typing", { text: "Demo is typing..." });
withUser(userId, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: channelId,
is_typing: false,
})
);
await contains(".o-discuss-Typing");
await contains(".o-discuss-Typing", { count: 0, text: "Demo is typing...)" });
});
test('[text composer] assume other member typing status becomes "no longer is typing" after long without any updated typing status', async () => {
const pyEnv = await startServer();
const userId = pyEnv["res.users"].create({ name: "Demo" });
const partnerId = pyEnv["res.partner"].create({ name: "Demo", user_ids: [userId] });
const channelId = pyEnv["discuss.channel"].create({
name: "channel",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
});
await start();
await openDiscuss(channelId);
await advanceTime(Store.FETCH_DATA_DEBOUNCE_DELAY);
await contains(".o-discuss-Typing");
await contains(".o-discuss-Typing", { count: 0, text: "Demo is typing...)" });
// simulate receive typing notification from demo "is typing"
withUser(userId, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: channelId,
is_typing: true,
})
);
await contains(".o-discuss-Typing", { text: "Demo is typing..." });
await advanceTime(Store.OTHER_LONG_TYPING);
await contains(".o-discuss-Typing", { count: 0, text: "Demo is typing...)" });
});
test.tags("html composer");
test('assume other member typing status becomes "no longer is typing" after long without any updated typing status', async () => {
const pyEnv = await startServer();
const userId = pyEnv["res.users"].create({ name: "Demo" });
const partnerId = pyEnv["res.partner"].create({ name: "Demo", user_ids: [userId] });
const channelId = pyEnv["discuss.channel"].create({
name: "channel",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
});
await start();
const composerService = getService("mail.composer");
composerService.setHtmlComposer();
await openDiscuss(channelId);
await advanceTime(Store.FETCH_DATA_DEBOUNCE_DELAY);
await contains(".o-discuss-Typing");
await contains(".o-discuss-Typing", { count: 0, text: "Demo is typing...)" });
withUser(userId, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: channelId,
is_typing: true,
})
);
await contains(".o-discuss-Typing", { text: "Demo is typing..." });
await advanceTime(Store.OTHER_LONG_TYPING);
await contains(".o-discuss-Typing", { count: 0, text: "Demo is typing...)" });
});
test('"is typing" timeout should work even when 2 notify_typing happen at the exact same time', async () => {
const pyEnv = await startServer();
const userId = pyEnv["res.users"].create({ name: "Demo" });
const partnerId = pyEnv["res.partner"].create({ name: "Demo", user_ids: [userId] });
const channelId = pyEnv["discuss.channel"].create({
name: "channel",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
});
await start();
await openDiscuss(channelId);
await advanceTime(Store.FETCH_DATA_DEBOUNCE_DELAY);
mockDate("2024-01-01 12:00:00");
await withUser(userId, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: channelId,
is_typing: false,
})
);
await withUser(userId, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: channelId,
is_typing: true,
})
);
await contains(".o-discuss-Typing", { text: "Demo is typing..." });
await advanceTime(Store.OTHER_LONG_TYPING);
await contains(".o-discuss-Typing", { count: 0, text: "Demo is typing..." });
});
test('[text composer] other member typing status "is typing" refreshes of assuming no longer typing', async () => {
const pyEnv = await startServer();
const userId = pyEnv["res.users"].create({ name: "Demo" });
const partnerId = pyEnv["res.partner"].create({ name: "Demo", user_ids: [userId] });
const channelId = pyEnv["discuss.channel"].create({
name: "channel",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
});
await start();
await openDiscuss(channelId);
await advanceTime(Store.FETCH_DATA_DEBOUNCE_DELAY);
await contains(".o-discuss-Typing");
await contains(".o-discuss-Typing", { count: 0, text: "Demo is typing...)" });
// simulate receive typing notification from demo "is typing"
withUser(userId, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: channelId,
is_typing: true,
})
);
await contains(".o-discuss-Typing", { text: "Demo is typing..." });
// simulate receive typing notification from demo "is typing" again after long time.
await advanceTime(LONG_TYPING);
await withUser(userId, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: channelId,
is_typing: true,
})
);
await advanceTime(LONG_TYPING);
await contains(".o-discuss-Typing", { text: "Demo is typing..." });
await advanceTime(Store.OTHER_LONG_TYPING - LONG_TYPING);
await contains(".o-discuss-Typing", { count: 0, text: "Demo is typing...)" });
});
test.tags("html composer");
test('other member typing status "is typing" refreshes of assuming no longer typing', async () => {
const pyEnv = await startServer();
const userId = pyEnv["res.users"].create({ name: "Demo" });
const partnerId = pyEnv["res.partner"].create({ name: "Demo", user_ids: [userId] });
const channelId = pyEnv["discuss.channel"].create({
name: "channel",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
});
await start();
const composerService = getService("mail.composer");
composerService.setHtmlComposer();
await openDiscuss(channelId);
await advanceTime(Store.FETCH_DATA_DEBOUNCE_DELAY);
await contains(".o-discuss-Typing");
await contains(".o-discuss-Typing", { count: 0, text: "Demo is typing...)" });
withUser(userId, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: channelId,
is_typing: true,
})
);
await contains(".o-discuss-Typing", { text: "Demo is typing..." });
await advanceTime(LONG_TYPING);
await withUser(userId, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: channelId,
is_typing: true,
})
);
await advanceTime(LONG_TYPING);
await contains(".o-discuss-Typing", { text: "Demo is typing..." });
await advanceTime(Store.OTHER_LONG_TYPING - LONG_TYPING);
await contains(".o-discuss-Typing", { count: 0, text: "Demo is typing...)" });
});
test('[text composer] receive several other members typing status "is typing"', async () => {
const pyEnv = await startServer();
const [userId_1, userId_2, userId_3] = pyEnv["res.users"].create([
{ name: "Other 10" },
{ name: "Other 11" },
{ name: "Other 12" },
]);
const [partnerId_1, partnerId_2, partnerId_3] = pyEnv["res.partner"].create([
{ name: "Other 10", user_ids: [userId_1] },
{ name: "Other 11", user_ids: [userId_2] },
{ name: "Other 12", user_ids: [userId_3] },
]);
const channelId = pyEnv["discuss.channel"].create({
name: "channel",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId_1 }),
Command.create({ partner_id: partnerId_2 }),
Command.create({ partner_id: partnerId_3 }),
],
});
await start();
await openDiscuss(channelId);
await contains(".o-discuss-Typing");
await contains(".o-discuss-Typing", { count: 0, text: "Demo is typing...)" });
// simulate receive typing notification from other 10 (is typing)
withUser(userId_1, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: channelId,
is_typing: true,
})
);
await contains(".o-discuss-Typing", { text: "Other 10 is typing..." });
// simulate receive typing notification from other 11 (is typing)
withUser(userId_2, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: channelId,
is_typing: true,
})
);
await contains(".o-discuss-Typing", { text: "Other 10 and Other 11 are typing..." });
// simulate receive typing notification from other 12 (is typing)
withUser(userId_3, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: channelId,
is_typing: true,
})
);
await contains(".o-discuss-Typing", { text: "Other 10, Other 11 and more are typing..." });
// simulate receive typing notification from other 10 (no longer is typing)
withUser(userId_1, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: channelId,
is_typing: false,
})
);
await contains(".o-discuss-Typing", { text: "Other 11 and Other 12 are typing..." });
// simulate receive typing notification from other 10 (is typing again)
withUser(userId_1, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: channelId,
is_typing: true,
})
);
await contains(".o-discuss-Typing", { text: "Other 11, Other 12 and more are typing..." });
});
test.tags("html composer");
test('receive several other members typing status "is typing"', async () => {
const pyEnv = await startServer();
const [userId_1, userId_2, userId_3] = pyEnv["res.users"].create([
{ name: "Other 10" },
{ name: "Other 11" },
{ name: "Other 12" },
]);
const [partnerId_1, partnerId_2, partnerId_3] = pyEnv["res.partner"].create([
{ name: "Other 10", user_ids: [userId_1] },
{ name: "Other 11", user_ids: [userId_2] },
{ name: "Other 12", user_ids: [userId_3] },
]);
const channelId = pyEnv["discuss.channel"].create({
name: "channel",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId_1 }),
Command.create({ partner_id: partnerId_2 }),
Command.create({ partner_id: partnerId_3 }),
],
});
await start();
const composerService = getService("mail.composer");
composerService.setHtmlComposer();
await openDiscuss(channelId);
await contains(".o-discuss-Typing");
await contains(".o-discuss-Typing", { count: 0, text: "Demo is typing...)" });
withUser(userId_1, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: channelId,
is_typing: true,
})
);
await contains(".o-discuss-Typing", { text: "Other 10 is typing..." });
withUser(userId_2, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: channelId,
is_typing: true,
})
);
await contains(".o-discuss-Typing", { text: "Other 10 and Other 11 are typing..." });
withUser(userId_3, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: channelId,
is_typing: true,
})
);
await contains(".o-discuss-Typing", { text: "Other 10, Other 11 and more are typing..." });
withUser(userId_1, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: channelId,
is_typing: false,
})
);
await contains(".o-discuss-Typing", { text: "Other 11 and Other 12 are typing..." });
withUser(userId_1, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: channelId,
is_typing: true,
})
);
await contains(".o-discuss-Typing", { text: "Other 11, Other 12 and more are typing..." });
});
test("[text composer] current partner notify is typing to other thread members", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "general" });
let testEnded = false;
onRpcBefore("/discuss/channel/notify_typing", (args) => {
if (!testEnded) {
asyncStep(`notify_typing:${args.is_typing}`);
}
});
await start();
await openDiscuss(channelId);
await insertText(".o-mail-Composer-input", "a");
await waitForSteps(["notify_typing:true"]);
testEnded = true;
});
test.tags("html composer");
test("current partner notify is typing to other thread members", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "general" });
let testEnded = false;
onRpcBefore("/discuss/channel/notify_typing", (args) => {
if (!testEnded) {
asyncStep(`notify_typing:${args.is_typing}`);
}
});
await start();
const composerService = getService("mail.composer");
composerService.setHtmlComposer();
await openDiscuss(channelId);
await contains(".o-mail-Composer-html.odoo-editor-editable");
const editor = {
document,
editable: document.querySelector(".o-mail-Composer-html.odoo-editor-editable"),
};
await htmlInsertText(editor, "a");
await waitForSteps(["notify_typing:true"]);
testEnded = true;
});
test("[text composer] current partner notify is typing again to other members for long continuous typing", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "general" });
let testEnded = false;
onRpcBefore("/discuss/channel/notify_typing", (args) => {
if (!testEnded) {
asyncStep(`notify_typing:${args.is_typing}`);
}
});
await start();
await openDiscuss(channelId);
await advanceTime(Store.FETCH_DATA_DEBOUNCE_DELAY);
await insertText(".o-mail-Composer-input", "a");
await waitForSteps(["notify_typing:true"]);
// simulate current partner typing a character for a long time.
const elapseTickTime = SHORT_TYPING / 2;
for (let i = 0; i <= LONG_TYPING / elapseTickTime; i++) {
await insertText(".o-mail-Composer-input", "a");
await advanceTime(elapseTickTime);
}
await waitForSteps(["notify_typing:true"]);
testEnded = true;
});
test.tags("html composer");
test("current partner notify is typing again to other members for long continuous typing", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "general" });
let testEnded = false;
onRpcBefore("/discuss/channel/notify_typing", (args) => {
if (!testEnded) {
asyncStep(`notify_typing:${args.is_typing}`);
}
});
await start();
const composerService = getService("mail.composer");
composerService.setHtmlComposer();
await openDiscuss(channelId);
await contains(".o-mail-Composer-html.odoo-editor-editable");
await advanceTime(Store.FETCH_DATA_DEBOUNCE_DELAY);
const editor = {
document,
editable: document.querySelector(".o-mail-Composer-html.odoo-editor-editable"),
};
await htmlInsertText(editor, "a");
await waitForSteps(["notify_typing:true"]);
const elapseTickTime = SHORT_TYPING / 2;
for (let i = 0; i <= LONG_TYPING / elapseTickTime; i++) {
await htmlInsertText(editor, "a");
await advanceTime(elapseTickTime);
}
await waitForSteps(["notify_typing:true"]);
testEnded = true;
});
test("[text composer] current partner notify no longer is typing to thread members after 5 seconds inactivity", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "general" });
onRpcBefore("/discuss/channel/notify_typing", (args) =>
asyncStep(`notify_typing:${args.is_typing}`)
);
await start();
await openDiscuss(channelId);
await advanceTime(Store.FETCH_DATA_DEBOUNCE_DELAY);
await insertText(".o-mail-Composer-input", "a");
await waitForSteps(["notify_typing:true"]);
await advanceTime(SHORT_TYPING);
await waitForSteps(["notify_typing:false"]);
});
test.tags("html composer");
test("current partner notify no longer is typing to thread members after 5 seconds inactivity", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "general" });
onRpcBefore("/discuss/channel/notify_typing", (args) =>
asyncStep(`notify_typing:${args.is_typing}`)
);
await start();
const composerService = getService("mail.composer");
composerService.setHtmlComposer();
await openDiscuss(channelId);
await contains(".o-mail-Composer-html.odoo-editor-editable");
const editor = {
document,
editable: document.querySelector(".o-mail-Composer-html.odoo-editor-editable"),
};
await htmlInsertText(editor, "a");
await waitForSteps(["notify_typing:true"]);
await advanceTime(SHORT_TYPING);
await waitForSteps(["notify_typing:false"]);
});
test("[text composer] current partner is typing should not translate on textual typing status", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "general" });
let testEnded = false;
onRpcBefore("/discuss/channel/notify_typing", (args) => {
if (!testEnded) {
asyncStep(`notify_typing:${args.is_typing}`);
}
});
await start();
await openDiscuss(channelId);
await insertText(".o-mail-Composer-input", "a");
await waitForSteps(["notify_typing:true"]);
await contains(".o-discuss-Typing");
await contains(".o-discuss-Typing", { count: 0, text: "Demo is typing...)" });
testEnded = true;
});
test.tags("html composer");
test("current partner is typing should not translate on textual typing status", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "general" });
let testEnded = false;
onRpcBefore("/discuss/channel/notify_typing", (args) => {
if (!testEnded) {
asyncStep(`notify_typing:${args.is_typing}`);
}
});
await start();
const composerService = getService("mail.composer");
composerService.setHtmlComposer();
await openDiscuss(channelId);
await contains(".o-mail-Composer-html.odoo-editor-editable");
const editor = {
document,
editable: document.querySelector(".o-mail-Composer-html.odoo-editor-editable"),
};
await htmlInsertText(editor, "a");
await waitForSteps(["notify_typing:true"]);
await contains(".o-discuss-Typing");
await contains(".o-discuss-Typing", { count: 0, text: "Demo is typing...)" });
testEnded = true;
});
test("[text composer] chat: correspondent is typing", async () => {
const pyEnv = await startServer();
const userId = pyEnv["res.users"].create({ name: "Demo" });
const partnerId = pyEnv["res.partner"].create({
im_status: "online",
name: "Demo",
user_ids: [userId],
});
const channelId = pyEnv["discuss.channel"].create({
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
channel_type: "chat",
});
await start();
await openDiscuss();
await contains(".o-mail-DiscussSidebarChannel .o-mail-DiscussSidebarChannel-threadIcon");
await contains(".fa-circle.text-success");
// simulate receive typing notification from demo "is typing"
withUser(userId, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: channelId,
is_typing: true,
})
);
await contains(".o-discuss-Typing-icon[title='Demo is typing...']");
// simulate receive typing notification from demo "no longer is typing"
withUser(userId, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: channelId,
is_typing: false,
})
);
await contains(".fa-circle.text-success");
});
test.tags("html composer");
test("chat: correspondent is typing", async () => {
const pyEnv = await startServer();
const userId = pyEnv["res.users"].create({ name: "Demo" });
const partnerId = pyEnv["res.partner"].create({
im_status: "online",
name: "Demo",
user_ids: [userId],
});
const channelId = pyEnv["discuss.channel"].create({
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
channel_type: "chat",
});
await start();
const composerService = getService("mail.composer");
composerService.setHtmlComposer();
await openDiscuss();
await contains(".o-mail-DiscussSidebarChannel .o-mail-DiscussSidebarChannel-threadIcon");
await contains(".fa-circle.text-success");
withUser(userId, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: channelId,
is_typing: true,
})
);
await contains(".o-discuss-Typing-icon[title='Demo is typing...']");
withUser(userId, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: channelId,
is_typing: false,
})
);
await contains(".fa-circle.text-success");
});
test("[text composer] chat: correspondent is typing in chat window", async () => {
const pyEnv = await startServer();
const userId = pyEnv["res.users"].create({ name: "Demo" });
const partnerId = pyEnv["res.partner"].create({
im_status: "online",
name: "Demo",
user_ids: [userId],
});
const channelId = pyEnv["discuss.channel"].create({
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
channel_type: "chat",
});
await start();
await click(".o_menu_systray i[aria-label='Messages']");
await click(".o-mail-NotificationItem");
await contains("[title='Demo is typing...']", { count: 0 });
// simulate receive typing notification from demo "is typing"
withUser(userId, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: channelId,
is_typing: true,
})
);
await contains("[title='Demo is typing...']", { count: 2 }); // icon in header & text above composer
// simulate receive typing notification from demo "no longer is typing"
withUser(userId, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: channelId,
is_typing: false,
})
);
await contains("[title='Demo is typing...']", { count: 0 });
});
test.tags("html composer");
test("chat: correspondent is typing in chat window", async () => {
const pyEnv = await startServer();
const userId = pyEnv["res.users"].create({ name: "Demo" });
const partnerId = pyEnv["res.partner"].create({
im_status: "online",
name: "Demo",
user_ids: [userId],
});
const channelId = pyEnv["discuss.channel"].create({
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
channel_type: "chat",
});
await start();
const composerService = getService("mail.composer");
composerService.setHtmlComposer();
await click(".o_menu_systray i[aria-label='Messages']");
await click(".o-mail-NotificationItem");
await contains("[title='Demo is typing...']", { count: 0 });
withUser(userId, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: channelId,
is_typing: true,
})
);
await contains("[title='Demo is typing...']", { count: 2 });
withUser(userId, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: channelId,
is_typing: false,
})
);
await contains("[title='Demo is typing...']", { count: 0 });
});
test("[text composer] show typing in member list", async () => {
const pyEnv = await startServer();
const userId = pyEnv["res.users"].create({ name: "Other 10" });
const partnerId = pyEnv["res.partner"].create({ name: "Other 10", user_ids: [userId] });
const channelId = pyEnv["discuss.channel"].create({
name: "channel",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
});
await start();
await openDiscuss(channelId);
await contains(".o-discuss-ChannelMember", { count: 2 });
withUser(userId, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: channelId,
is_typing: true,
})
);
await contains(".o-discuss-ChannelMemberList [title='Other 10 is typing...']");
withUser(serverState.userId, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: channelId,
is_typing: true,
})
);
await contains(
`.o-discuss-ChannelMemberList [title='${serverState.partnerName} is typing...']`
);
});
test.tags("html composer");
test("show typing in member list", async () => {
const pyEnv = await startServer();
const userId = pyEnv["res.users"].create({ name: "Other 10" });
const partnerId = pyEnv["res.partner"].create({ name: "Other 10", user_ids: [userId] });
const channelId = pyEnv["discuss.channel"].create({
name: "channel",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
});
await start();
const composerService = getService("mail.composer");
composerService.setHtmlComposer();
await openDiscuss(channelId);
await contains(".o-discuss-ChannelMember", { count: 2 });
withUser(userId, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: channelId,
is_typing: true,
})
);
await contains(".o-discuss-ChannelMemberList [title='Other 10 is typing...']");
withUser(serverState.userId, () =>
rpc("/discuss/channel/notify_typing", {
channel_id: channelId,
is_typing: true,
})
);
await contains(
`.o-discuss-ChannelMemberList [title='${serverState.partnerName} is typing...']`
);
});
test("[text composer] switching to another channel triggers notify_typing to stop", async () => {
const pyEnv = await startServer();
const userId = pyEnv["res.users"].create({ name: "Demo" });
const partnerId = pyEnv["res.partner"].create({
im_status: "online",
name: "Demo",
user_ids: [userId],
});
const chatId = pyEnv["discuss.channel"].create({
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
channel_type: "chat",
});
pyEnv["discuss.channel"].create({ name: "general" });
onRpcBefore("/discuss/channel/notify_typing", (args) =>
asyncStep(`notify_typing:${args.is_typing}`)
);
await start();
await openDiscuss(chatId);
await insertText(".o-mail-Composer-input", "a");
await waitForSteps(["notify_typing:true"]);
await click(".o-mail-DiscussSidebar-item", { text: "general" });
await advanceTime(SHORT_TYPING / 2);
await waitForSteps(["notify_typing:false"]);
});
test.tags("html composer");
test("switching to another channel triggers notify_typing to stop", async () => {
const pyEnv = await startServer();
const userId = pyEnv["res.users"].create({ name: "Demo" });
const partnerId = pyEnv["res.partner"].create({
im_status: "online",
name: "Demo",
user_ids: [userId],
});
const chatId = pyEnv["discuss.channel"].create({
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
channel_type: "chat",
});
pyEnv["discuss.channel"].create({ name: "general" });
onRpcBefore("/discuss/channel/notify_typing", (args) =>
asyncStep(`notify_typing:${args.is_typing}`)
);
await start();
const composerService = getService("mail.composer");
composerService.setHtmlComposer();
await openDiscuss(chatId);
await contains(".o-mail-Composer-html.odoo-editor-editable");
const editor = {
document,
editable: document.querySelector(".o-mail-Composer-html.odoo-editor-editable"),
};
await htmlInsertText(editor, "a");
await waitForSteps(["notify_typing:true"]);
await click(".o-mail-DiscussSidebar-item", { text: "general" });
await advanceTime(SHORT_TYPING / 2);
await waitForSteps(["notify_typing:false"]);
});

View file

@ -0,0 +1,96 @@
import {
click,
contains,
defineMailModels,
mockGetMedia,
openDiscuss,
patchVoiceMessageAudio,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { describe, globals, test } from "@odoo/hoot";
import { Deferred, mockDate } from "@odoo/hoot-mock";
import { Command, patchWithCleanup, serverState } from "@web/../tests/web_test_helpers";
import { loadLamejs } from "@mail/discuss/voice_message/common/voice_message_service";
import { VoicePlayer } from "@mail/discuss/voice_message/common/voice_player";
import { patchable } from "@mail/discuss/voice_message/common/voice_recorder";
import { Mp3Encoder } from "@mail/discuss/voice_message/common/mp3_encoder";
describe.current.tags("desktop");
defineMailModels();
test("make voice message in chat", async () => {
const file = new File([new Uint8Array(25000)], "test.mp3", { type: "audio/mp3" });
const voicePlayerDrawing = new Deferred();
patchWithCleanup(Mp3Encoder.prototype, {
encode() {},
finish() {
return Array(500).map(() => new Int8Array());
},
});
patchWithCleanup(patchable, { makeFile: () => file });
patchWithCleanup(VoicePlayer.prototype, {
async drawWave(...args) {
voicePlayerDrawing.resolve();
return super.drawWave(...args);
},
async fetchFile() {
return super.fetchFile("/mail/static/src/audio/call-invitation.mp3");
},
_fetch(url) {
if (url.includes("call-invitation.mp3")) {
const realFetch = globals.fetch;
return realFetch(...arguments);
}
return super._fetch(...arguments);
},
});
mockGetMedia();
const resources = patchVoiceMessageAudio();
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Demo" });
const channelId = pyEnv["discuss.channel"].create({
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
channel_type: "chat",
});
await start();
await openDiscuss(channelId);
await loadLamejs(); // simulated AudioProcess.process() requires lamejs fully loaded
await click(".o-mail-Composer button[title='More Actions']");
await contains(".dropdown-item:contains('Voice Message')");
mockDate("2023-07-31 13:00:00");
await click(".dropdown-item:contains('Voice Message')");
await contains(".o-mail-VoiceRecorder", { text: "00 : 00" });
/**
* Simulate 10 sec elapsed.
* `patchDate` does not freeze the time, it merely changes the value of "now" at the time it was
* called. The code of click following the first `patchDate` doesn't actually happen at the time
* that was specified, but few miliseconds later (8 ms on my machine).
* The process following the next `patchDate` is intended to be between 10s and 11s later than
* the click, because the test wants to assert a 10 sec counter, and the two dates are
* substracted and then rounded down in the code (it means absolute values are irrelevant here).
* The problem with aiming too close to a 10s difference is that if the click is longer than
* the following process, it will round down to 9s.
* The problem with aiming too close to a 11s difference is that if the click is shorter than
* the following process, it will round down to 11s.
* The best bet is therefore to use 10s + 500ms difference.
*/
mockDate("2023-07-31 13:00:10.500");
// simulate some microphone data
resources.audioProcessor.process([[new Float32Array(128)]]);
await contains(".o-mail-VoiceRecorder", { text: "00 : 10" });
await click(".o-mail-Composer button[title='Stop Recording']");
await contains(".o-mail-VoicePlayer");
// wait for audio stream decode + drawing of waves
await voicePlayerDrawing;
await contains(".o-mail-VoicePlayer button[title='Play']");
await contains(".o-mail-VoicePlayer canvas", { count: 2 }); // 1 for global waveforms, 1 for played waveforms
await contains(".o-mail-VoicePlayer", { text: "00 : 03" }); // duration of call-invitation_.mp3
await click(".o-mail-Composer button[title='More Actions']");
await contains(".dropdown-item:contains('Attach Files')"); // check menu loaded
await contains(".dropdown-item:contains('Voice Message')", { count: 0 }); // only 1 voice message at a time
});

View file

@ -0,0 +1,60 @@
import { addBusServiceListeners, lockWebsocketConnect } from "@bus/../tests/bus_test_helpers";
import { getWebSocketWorker } from "@bus/../tests/mock_websocket";
import { WEBSOCKET_CLOSE_CODES } from "@bus/workers/websocket_worker";
import { defineMailModels, openDiscuss, start } from "@mail/../tests/mail_test_helpers";
import { describe, expect, test } from "@odoo/hoot";
import { animationFrame, runAllTimers, waitFor, waitForNone } from "@odoo/hoot-dom";
import {
asyncStep,
makeMockServer,
MockServer,
patchWithCleanup,
waitForSteps,
} from "@web/../tests/web_test_helpers";
import { browser } from "@web/core/browser/browser";
defineMailModels();
describe.current.tags("desktop");
test("show warning when bus connection encounters issues", async () => {
await makeMockServer();
// Avoid excessively long exponential backoff.
patchWithCleanup(getWebSocketWorker(), {
INITIAL_RECONNECT_DELAY: 50,
RECONNECT_JITTER: 50,
});
// The bus service listens to online/offline events. Prevent them to make the
// test deterministic.
for (const event of ["online", "offline"]) {
browser.addEventListener(
event,
(ev) => {
ev.preventDefault();
ev.stopImmediatePropagation();
},
{ capture: true }
);
}
addBusServiceListeners(
["BUS:CONNECT", () => asyncStep("BUS:CONNECT")],
["BUS:RECONNECT", () => asyncStep("BUS:RECONNECT")],
["BUS:RECONNECTING", () => asyncStep("BUS:RECONNECTING")]
);
await start();
await openDiscuss();
await waitForSteps(["BUS:CONNECT"]);
const unlockWebsocket = lockWebsocketConnect();
MockServer.env["bus.bus"]._simulateDisconnection(WEBSOCKET_CLOSE_CODES.ABNORMAL_CLOSURE);
await waitForSteps(["BUS:RECONNECTING"]);
expect(await waitFor(".o-bus-ConnectionAlert", { timeout: 2500 })).toHaveText(
"Real-time connection lost..."
);
await runAllTimers();
await animationFrame();
expect(".o-bus-ConnectionAlert").toHaveText("Real-time connection lost...");
unlockWebsocket();
await runAllTimers();
await waitForSteps(["BUS:RECONNECT"]);
await waitForNone(".o-bus-ConnectionAlert");
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,108 @@
import {
click,
contains,
defineMailModels,
openDiscuss,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { Store } from "@mail/core/common/store_service";
import { describe, test } from "@odoo/hoot";
import { Command, patchWithCleanup, serverState } from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
defineMailModels();
test("initially online", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Demo", im_status: "online" });
const channelId = pyEnv["discuss.channel"].create({
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
channel_type: "chat",
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-DiscussContent-header .o-mail-ImStatus i[title='Online']");
});
test("initially offline", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Demo", im_status: "offline" });
const channelId = pyEnv["discuss.channel"].create({
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
channel_type: "chat",
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-DiscussContent-header .o-mail-ImStatus i[title='Offline']");
});
test("initially away", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Demo", im_status: "away" });
const channelId = pyEnv["discuss.channel"].create({
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
channel_type: "chat",
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-DiscussContent-header .o-mail-ImStatus i[title='Idle']");
});
test("change icon on change partner im_status", async () => {
patchWithCleanup(Store, { IM_STATUS_DEBOUNCE_DELAY: 0 });
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ channel_type: "chat" });
pyEnv["res.partner"].write([serverState.partnerId], { im_status: "online" });
await start();
await openDiscuss(channelId);
await contains(".o-mail-DiscussContent-header .o-mail-ImStatus i[title='Online']");
pyEnv["res.partner"].write([serverState.partnerId], { im_status: "offline" });
pyEnv["bus.bus"]._sendone(serverState.partnerId, "bus.bus/im_status_updated", {
partner_id: serverState.partnerId,
im_status: "offline",
presence_status: "offline",
});
await contains(".o-mail-DiscussContent-header .o-mail-ImStatus i[title='Offline']");
pyEnv["res.partner"].write([serverState.partnerId], { im_status: "away" });
pyEnv["bus.bus"]._sendone(serverState.partnerId, "bus.bus/im_status_updated", {
partner_id: serverState.partnerId,
im_status: "away",
presence_status: "away",
});
await contains(".o-mail-DiscussContent-header .o-mail-ImStatus i[title='Idle']");
pyEnv["res.partner"].write([serverState.partnerId], { im_status: "online" });
pyEnv["bus.bus"]._sendone(serverState.partnerId, "bus.bus/im_status_updated", {
partner_id: serverState.partnerId,
im_status: "online",
presence_status: "online",
});
await contains(".o-mail-DiscussContent-header .o-mail-ImStatus i[title='Online']");
});
test("show im status in messaging menu preview of chat", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Demo", im_status: "online" });
pyEnv["discuss.channel"].create({
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
channel_type: "chat",
});
await start();
await click(".o_menu_systray i[aria-label='Messages']");
await contains(".o-mail-NotificationItem", {
text: "Demo",
contains: ["i[aria-label='User is online']"],
});
});

View file

@ -0,0 +1,762 @@
import {
click,
contains,
defineMailModels,
insertText,
onRpcBefore,
openDiscuss,
scroll,
start,
startServer,
triggerHotkey,
} from "@mail/../tests/mail_test_helpers";
import { describe, expect, test } from "@odoo/hoot";
import { Deferred } from "@odoo/hoot-mock";
import {
asyncStep,
mockService,
serverState,
waitForSteps,
withUser,
} from "@web/../tests/web_test_helpers";
import { rpc } from "@web/core/network/rpc";
describe.current.tags("desktop");
defineMailModels();
test("reply: discard on reply button toggle", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
const messageId = pyEnv["mail.message"].create({
body: "not empty",
model: "res.partner",
needaction: true,
res_id: partnerId,
});
pyEnv["mail.notification"].create({
mail_message_id: messageId,
notification_status: "sent",
notification_type: "inbox",
res_partner_id: serverState.partnerId,
});
await start();
await openDiscuss("mail.box_inbox");
await contains(".o-mail-Message");
await click("[title='Expand']");
await click(".o-dropdown-item:contains('Reply')");
await contains(".o-mail-Composer");
await click("[title='Expand']");
await click(".o-dropdown-item:contains('Reply')");
await contains(".o-mail-Composer", { count: 0 });
});
test.tags("focus required");
test("reply: discard on pressing escape", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({
email: "testpartnert@odoo.com",
name: "TestPartner",
});
const messageId = pyEnv["mail.message"].create({
body: "not empty",
model: "res.partner",
needaction: true,
res_id: partnerId,
});
pyEnv["mail.notification"].create({
mail_message_id: messageId,
notification_status: "sent",
notification_type: "inbox",
res_partner_id: serverState.partnerId,
});
await start();
await openDiscuss("mail.box_inbox");
await contains(".o-mail-Message");
await click("[title='Expand']");
await click(".o-dropdown-item:contains('Reply')");
await contains(".o-mail-Composer");
// Escape on emoji picker does not stop replying
await click(".o-mail-Composer button[title='Add Emojis']");
await contains(".o-EmojiPicker");
triggerHotkey("Escape");
await contains(".o-EmojiPicker", { count: 0 });
await contains(".o-mail-Composer");
// Escape on suggestion prompt does not stop replying
await insertText(".o-mail-Composer-input", "@");
await contains(".o-mail-Composer-suggestionList .o-open");
triggerHotkey("Escape");
await contains(".o-mail-Composer-suggestionList .o-open", { count: 0 });
await contains(".o-mail-Composer");
await click(".o-mail-Composer-input").catch(() => {});
await contains(".o-mail-Composer.o-focused");
triggerHotkey("Escape");
await contains(".o-mail-Composer", { count: 0 });
});
test('"reply to" composer should log note if message replied to is a note', async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Demo" });
const messageId = pyEnv["mail.message"].create({
body: "not empty",
subtype_id: pyEnv["mail.message.subtype"].search([
["subtype_xmlid", "=", "mail.mt_note"],
])[0],
model: "res.partner",
needaction: true,
res_id: partnerId,
});
pyEnv["mail.notification"].create({
mail_message_id: messageId,
notification_status: "sent",
notification_type: "inbox",
res_partner_id: serverState.partnerId,
});
onRpcBefore("/mail/message/post", (args) => {
asyncStep("/mail/message/post");
expect(args.post_data.message_type).toBe("comment");
expect(args.post_data.subtype_xmlid).toBe("mail.mt_note");
});
await start();
await openDiscuss("mail.box_inbox");
await contains(".o-mail-Message");
await click("[title='Expand']");
await click(".o-dropdown-item:contains('Reply')");
await contains(".o-mail-Composer [placeholder='Log an internal note…']");
await insertText(".o-mail-Composer-input", "Test");
await click(".o-mail-Composer button[title='Log']");
await contains(".o-mail-Composer", { count: 0 });
await waitForSteps(["/mail/message/post"]);
});
test('"reply to" composer should send message if message replied to is not a note', async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
const messageId = pyEnv["mail.message"].create({
body: "not empty",
subtype_id: pyEnv["mail.message.subtype"].search([
["subtype_xmlid", "=", "mail.mt_comment"],
])[0],
model: "res.partner",
needaction: true,
res_id: partnerId,
});
pyEnv["mail.notification"].create({
mail_message_id: messageId,
notification_status: "sent",
notification_type: "inbox",
res_partner_id: serverState.partnerId,
});
onRpcBefore("/mail/message/post", (args) => {
asyncStep("/mail/message/post");
expect(args.post_data.message_type).toBe("comment");
expect(args.post_data.subtype_xmlid).toBe("mail.mt_comment");
});
await start();
await openDiscuss("mail.box_inbox");
await contains(".o-mail-Message");
await click("[title='Expand']");
await click(".o-dropdown-item:contains('Reply')");
await contains(".o-mail-Composer [placeholder='Send a message to followers…']");
await insertText(".o-mail-Composer-input", "Test");
await click(".o-mail-Composer button[title='Send']:enabled");
await contains(".o-mail-Composer button[title='Send']", { count: 0 });
await waitForSteps(["/mail/message/post"]);
});
test("show subject of message in Inbox", async () => {
const pyEnv = await startServer();
const messageId = pyEnv["mail.message"].create({
body: "not empty",
model: "discuss.channel",
needaction: true,
subject: "Salutations, voyageur",
});
pyEnv["mail.notification"].create({
mail_message_id: messageId,
notification_status: "sent",
notification_type: "inbox",
res_partner_id: serverState.partnerId,
});
await start();
await openDiscuss("mail.box_inbox");
await contains(".o-mail-Message", { text: "Subject: Salutations, voyageurnot empty" });
});
test("show subject of message in history", async () => {
const pyEnv = await startServer();
const messageId = pyEnv["mail.message"].create({
body: "not empty",
model: "discuss.channel",
subject: "Salutations, voyageur",
});
pyEnv["mail.notification"].create({
is_read: true,
mail_message_id: messageId,
notification_status: "sent",
notification_type: "inbox",
res_partner_id: serverState.partnerId,
});
await start();
await openDiscuss("mail.box_history");
await contains(".o-mail-Message", { text: "Subject: Salutations, voyageurnot empty" });
});
test("subject should not be shown when subject is the same as the thread name", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "Salutations, voyageur" });
const messageId = pyEnv["mail.message"].create({
body: "not empty",
model: "discuss.channel",
res_id: channelId,
needaction: true,
subject: "Salutations, voyageur",
});
pyEnv["mail.notification"].create({
mail_message_id: messageId,
notification_status: "sent",
notification_type: "inbox",
res_partner_id: serverState.partnerId,
});
await start();
await openDiscuss("mail.box_inbox");
await contains(".o-mail-Message");
await contains(".o-mail-Message", {
count: 0,
text: "Subject: Salutations, voyageurnot empty",
});
});
test("subject should not be shown when subject is the same as the thread name and both have the same prefix", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "Re: Salutations, voyageur" });
const messageId = pyEnv["mail.message"].create({
body: "not empty",
model: "discuss.channel",
res_id: channelId,
needaction: true,
subject: "Re: Salutations, voyageur",
});
pyEnv["mail.notification"].create({
mail_message_id: messageId,
notification_status: "sent",
notification_type: "inbox",
res_partner_id: serverState.partnerId,
});
await start();
await openDiscuss("mail.box_inbox");
await contains(".o-mail-Message");
await contains(".o-mail-Message", {
count: 0,
text: "Subject: Salutations, voyageurnot empty",
});
});
test('subject should not be shown when subject differs from thread name only by the "Re:" prefix', async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "Salutations, voyageur" });
const messageId = pyEnv["mail.message"].create({
body: "not empty",
model: "discuss.channel",
res_id: channelId,
needaction: true,
subject: "Re: Salutations, voyageur",
});
pyEnv["mail.notification"].create({
mail_message_id: messageId,
notification_status: "sent",
notification_type: "inbox",
res_partner_id: serverState.partnerId,
});
await start();
await openDiscuss("mail.box_inbox");
await contains(".o-mail-Message");
await contains(".o-mail-Message", {
count: 0,
text: "Subject: Salutations, voyageurnot empty",
});
});
test('subject should not be shown when subject differs from thread name only by the "Fw:" and "Re:" prefix', async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "Salutations, voyageur" });
const messageId = pyEnv["mail.message"].create({
body: "not empty",
model: "discuss.channel",
res_id: channelId,
needaction: true,
subject: "Fw: Re: Salutations, voyageur",
});
pyEnv["mail.notification"].create({
mail_message_id: messageId,
notification_status: "sent",
notification_type: "inbox",
res_partner_id: serverState.partnerId,
});
await start();
await openDiscuss("mail.box_inbox");
await contains(".o-mail-Message");
await contains(".o-mail-Message", {
count: 0,
text: "Subject: Salutations, voyageurnot empty",
});
});
test("subject should be shown when the thread name has an extra prefix compared to subject", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "Re: Salutations, voyageur" });
const messageId = pyEnv["mail.message"].create({
body: "not empty",
model: "discuss.channel",
res_id: channelId,
needaction: true,
subject: "Salutations, voyageur",
});
pyEnv["mail.notification"].create({
mail_message_id: messageId,
notification_status: "sent",
notification_type: "inbox",
res_partner_id: serverState.partnerId,
});
await start();
await openDiscuss("mail.box_inbox");
await contains(".o-mail-Message");
await contains(".o-mail-Message", {
count: 0,
text: "Subject: Salutations, voyageurnot empty",
});
});
test('subject should not be shown when subject differs from thread name only by the "fw:" prefix and both contain another common prefix', async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "Re: Salutations, voyageur" });
const messageId = pyEnv["mail.message"].create({
body: "not empty",
model: "discuss.channel",
res_id: channelId,
needaction: true,
subject: "fw: re: Salutations, voyageur",
});
pyEnv["mail.notification"].create({
mail_message_id: messageId,
notification_status: "sent",
notification_type: "inbox",
res_partner_id: serverState.partnerId,
});
await start();
await openDiscuss("mail.box_inbox");
await contains(".o-mail-Message");
await contains(".o-mail-Message", {
count: 0,
text: "Subject: Salutations, voyageurnot empty",
});
});
test('subject should not be shown when subject differs from thread name only by the "Re: Re:" prefix', async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "Salutations, voyageur" });
const messageId = pyEnv["mail.message"].create({
body: "not empty",
model: "discuss.channel",
res_id: channelId,
needaction: true,
subject: "Re: Re: Salutations, voyageur",
});
pyEnv["mail.notification"].create({
mail_message_id: messageId,
notification_status: "sent",
notification_type: "inbox",
res_partner_id: serverState.partnerId,
});
await start();
await openDiscuss("mail.box_inbox");
await contains(".o-mail-Message");
await contains(".o-mail-Message", {
count: 0,
text: "Subject: Salutations, voyageurnot empty",
});
});
test("inbox: mark all messages as read", async () => {
const pyEnv = await startServer();
pyEnv["res.users"].write(serverState.userId, { notification_type: "inbox" });
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
const [messageId_1, messageId_2] = pyEnv["mail.message"].create([
{
body: "not empty",
model: "discuss.channel",
needaction: true,
res_id: channelId,
},
{
body: "not empty",
model: "discuss.channel",
needaction: true,
res_id: channelId,
},
]);
pyEnv["mail.notification"].create([
{
mail_message_id: messageId_1,
notification_type: "inbox",
res_partner_id: serverState.partnerId,
},
{
mail_message_id: messageId_2,
notification_type: "inbox",
res_partner_id: serverState.partnerId,
},
]);
await start();
await openDiscuss("mail.box_inbox");
await contains("button", { text: "Inbox", contains: [".badge", { text: "2" }] });
await contains(".o-mail-DiscussSidebarChannel", {
contains: [
["span", { text: "General" }],
[".badge", { text: "2" }],
],
});
await contains(".o-mail-DiscussContent .o-mail-Message", { count: 2 });
await click(".o-mail-DiscussContent-header button:enabled", { text: "Mark all read" });
await contains("button", { text: "Inbox", contains: [".badge", { count: 0 }] });
await contains(".o-mail-DiscussSidebarChannel", {
contains: [
["span", { text: "General" }],
[".badge", { count: 0 }],
],
});
await contains(".o-mail-Message", { count: 0 });
await contains("button:disabled", { text: "Mark all read" });
});
test("inbox: mark as read should not display jump to present", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
const msgIds = pyEnv["mail.message"].create(
Array(30)
.keys()
.map((i) => ({
body: "not empty".repeat(100),
model: "discuss.channel",
needaction: true,
res_id: channelId,
}))
);
pyEnv["mail.notification"].create(
Array(30)
.keys()
.map((i) => ({
mail_message_id: msgIds[i],
notification_type: "inbox",
res_partner_id: serverState.partnerId,
}))
);
await start();
await openDiscuss("mail.box_inbox");
// scroll up so that there's the "Jump to Present".
// So that assertion of negative matches the positive assertion
await contains(".o-mail-Message", { count: 30 });
await scroll(".o-mail-Thread", 0);
await contains("[title='Jump to Present']");
await click(".o-mail-DiscussContent-header button:enabled", { text: "Mark all read" });
await contains("[title='Jump to Present']", { count: 0 });
});
test("click on (non-channel/non-partner) origin thread link should redirect to form view", async () => {
const pyEnv = await startServer();
const fakeId = pyEnv["res.fake"].create({ name: "Some record" });
const messageId = pyEnv["mail.message"].create({
body: "not empty",
model: "res.fake",
needaction: true,
res_id: fakeId,
});
pyEnv["mail.notification"].create({
mail_message_id: messageId,
notification_status: "sent",
notification_type: "inbox",
res_partner_id: serverState.partnerId,
});
const def = new Deferred();
mockService("action", {
async doAction(action) {
if (action?.res_model !== "res.fake") {
return super.doAction(...arguments);
}
// Callback of doing an action (action manager).
// Expected to be called on click on origin thread link,
// which redirects to form view of record related to origin thread
asyncStep("do-action");
expect(action.type).toBe("ir.actions.act_window");
expect(action.views).toEqual([[false, "form"]]);
expect(action.res_model).toBe("res.fake");
expect(action.res_id).toBe(fakeId);
def.resolve();
},
});
await start();
await openDiscuss("mail.box_inbox");
await contains(".o-mail-Message");
await click(".o-mail-Message-header a", { text: "Some record" });
await def;
await waitForSteps(["do-action"]);
});
test("inbox messages are never squashed", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
const channelId = pyEnv["discuss.channel"].create({ name: "test" });
const [messageId_1, messageId_2] = pyEnv["mail.message"].create([
{
author_id: partnerId,
body: "<p>body1</p>",
date: "2019-04-20 10:00:00",
message_type: "comment",
model: "discuss.channel",
needaction: true,
res_id: channelId,
},
{
author_id: partnerId,
body: "<p>body2</p>",
date: "2019-04-20 10:00:30",
message_type: "comment",
model: "discuss.channel",
needaction: true,
res_id: channelId,
},
]);
pyEnv["mail.notification"].create([
{
mail_message_id: messageId_1,
notification_status: "sent",
notification_type: "inbox",
res_partner_id: serverState.partnerId,
},
{
mail_message_id: messageId_2,
notification_status: "sent",
notification_type: "inbox",
res_partner_id: serverState.partnerId,
},
]);
await start();
await openDiscuss("mail.box_inbox");
await contains(".o-mail-Message", { count: 2 });
await contains(".o-mail-Message:not(.o-squashed)", { text: "body1" });
await contains(".o-mail-Message:not(.o-squashed)", { text: "body2" });
await click(".o-mail-DiscussSidebarChannel", { text: "test" });
await contains(".o-mail-Message.o-squashed", { text: "body2" });
});
test("reply: stop replying button click", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
const messageId = pyEnv["mail.message"].create({
body: "not empty",
model: "res.partner",
needaction: true,
res_id: partnerId,
});
pyEnv["mail.notification"].create({
mail_message_id: messageId,
notification_status: "sent",
notification_type: "inbox",
res_partner_id: serverState.partnerId,
});
await start();
await openDiscuss("mail.box_inbox");
await contains(".o-mail-Message");
await click("[title='Expand']");
await click(".o-dropdown-item:contains('Reply')");
await contains(".o-mail-Composer");
await contains("i[title='Stop replying']");
await click("i[title='Stop replying']");
await contains(".o-mail-Composer", { count: 0 });
});
test("error notifications should not be shown in Inbox", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Demo User" });
const messageId = pyEnv["mail.message"].create({
body: "not empty",
model: "res.partner",
needaction: true,
res_id: partnerId,
});
pyEnv["mail.notification"].create({
mail_message_id: messageId,
notification_status: "exception",
notification_type: "email",
res_partner_id: serverState.partnerId,
});
await start();
await openDiscuss("mail.box_inbox");
await contains(".o-mail-Message");
await contains(".o-mail-Message-header small", { text: "on Demo User" });
await contains(`.o-mail-Message-header a[href*='/odoo/res.partner/${partnerId}']`, {
text: "Demo User",
});
await contains(".o-mail-Message-notification", { count: 0 });
});
test("emptying inbox displays rainbow man in inbox", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
const messageId1 = pyEnv["mail.message"].create({
body: "not empty",
model: "discuss.channel",
needaction: true,
res_id: channelId,
});
pyEnv["mail.notification"].create([
{
mail_message_id: messageId1,
notification_type: "inbox",
res_partner_id: serverState.partnerId,
},
]);
await start();
await openDiscuss("mail.box_inbox");
await contains(".o-mail-Message");
await click("button:enabled", { text: "Mark all read" });
await contains(".o_reward_rainbow");
});
test("emptying inbox doesn't display rainbow man in another thread", async () => {
const pyEnv = await startServer();
pyEnv["res.users"].write(serverState.userId, { notification_type: "inbox" });
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
const partnerId = pyEnv["res.partner"].create({});
const messageId = pyEnv["mail.message"].create({
body: "not empty",
model: "res.partner",
needaction: true,
res_id: partnerId,
});
pyEnv["mail.notification"].create([
{
mail_message_id: messageId,
notification_type: "inbox",
res_partner_id: serverState.partnerId,
},
]);
await start();
await openDiscuss(channelId);
await contains("button", { text: "Inbox", contains: [".badge", { text: "1" }] });
const [partner] = pyEnv["res.partner"].read(serverState.partnerId);
pyEnv["bus.bus"]._sendone(partner, "mail.message/mark_as_read", {
message_ids: [messageId],
needaction_inbox_counter: 0,
});
await contains("button", { text: "Inbox", contains: [".badge", { count: 0 }] });
// weak test, no guarantee that we waited long enough for the potential rainbow man to show
await contains(".o_reward_rainbow", { count: 0 });
});
test("Counter should be incremented by 1 when receiving a message with a mention in a channel", async () => {
const pyEnv = await startServer();
pyEnv["res.users"].write(serverState.userId, { notification_type: "inbox" });
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
const partnerId = pyEnv["res.partner"].create({ name: "Thread" });
const partnerUserId = pyEnv["res.partner"].create({ name: "partner1" });
const userId = pyEnv["res.users"].create({ partner_id: partnerUserId });
const messageId = pyEnv["mail.message"].create({
body: "not empty",
model: "res.partner",
needaction: true,
res_id: partnerId,
});
pyEnv["mail.notification"].create([
{
mail_message_id: messageId,
notification_type: "inbox",
res_partner_id: serverState.partnerId,
},
]);
await start();
await openDiscuss("mail.box_inbox");
await contains("button", { text: "Inbox", contains: [".badge", { text: "1" }] });
const mention = [serverState.partnerId];
const mentionName = serverState.partnerName;
withUser(userId, () =>
rpc("/mail/message/post", {
post_data: {
body: `<a href="https://www.hoot.test/odoo/res.partner/17" class="o_mail_redirect" data-oe-id="${mention[0]}" data-oe-model="res.partner" target="_blank" contenteditable="false">@${mentionName}</a> mention`,
message_type: "comment",
partner_ids: mention,
subtype_xmlid: "mail.mt_comment",
},
thread_id: channelId,
thread_model: "discuss.channel",
})
);
await contains("button", { text: "Inbox", contains: [".badge", { text: "2" }] });
});
test("Clear need action counter when opening a channel", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
const [messageId1, messageId2] = pyEnv["mail.message"].create([
{
body: "not empty",
model: "discuss.channel",
needaction: true,
res_id: channelId,
},
{
body: "not empty",
model: "discuss.channel",
needaction: true,
res_id: channelId,
},
]);
pyEnv["mail.notification"].create([
{
mail_message_id: messageId1,
notification_type: "inbox",
res_partner_id: serverState.partnerId,
},
{
mail_message_id: messageId2,
notification_type: "inbox",
res_partner_id: serverState.partnerId,
},
]);
await start();
await openDiscuss("mail.box_inbox");
await contains(".o-mail-DiscussSidebar-item", {
text: "General",
contains: [".badge", { text: "2" }],
});
await click(".o-mail-DiscussSidebarChannel", { text: "General" });
await contains(".o-mail-DiscussSidebar-item", {
text: "General",
contains: [".badge", { count: 0 }],
});
});
test("can reply to email message", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
const messageId = pyEnv["mail.message"].create({
author_id: null,
email_from: "md@oilcompany.fr",
body: "an email message",
model: "res.partner",
needaction: true,
res_id: partnerId,
});
pyEnv["mail.notification"].create({
mail_message_id: messageId,
notification_status: "sent",
notification_type: "inbox",
res_partner_id: serverState.partnerId,
});
await start();
await openDiscuss("mail.box_inbox");
await contains(".o-mail-Message");
await click("[title='Expand']");
await click(".o-dropdown-item:contains('Reply')");
await contains(".o-mail-Composer", { text: "Replying to md@oilcompany.fr" });
});

View file

@ -0,0 +1,314 @@
import { describe, expect, test } from "@odoo/hoot";
import { animationFrame, Deferred, press, queryFirst, tick } from "@odoo/hoot-dom";
import {
asyncStep,
patchWithCleanup,
serverState,
waitForSteps,
} from "@web/../tests/web_test_helpers";
import {
SIZES,
click,
contains,
defineMailModels,
insertText,
isInViewportOf,
onRpcBefore,
openDiscuss,
openFormView,
patchUiSize,
scroll,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { PRESENT_VIEWPORT_THRESHOLD } from "@mail/core/common/thread";
describe.current.tags("desktop");
defineMailModels();
test("Basic jump to present when scrolling to outdated messages", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
for (let i = 0; i < 20; i++) {
pyEnv["mail.message"].create({
body: "Non Empty Body ".repeat(100),
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
});
}
await start();
await openDiscuss(channelId);
await contains(".o-mail-Message", { count: 20 });
await contains(".o-mail-Thread");
expect(document.querySelector(".o-mail-Thread").scrollHeight).toBeGreaterThan(
PRESENT_VIEWPORT_THRESHOLD * document.querySelector(".o-mail-Thread").clientHeight,
{ message: "should have enough scroll height to trigger jump to present" }
);
await click("[title='Jump to Present']");
await contains("[title='Jump to Present']", { count: 0 });
await contains(".o-mail-Thread", { scroll: "bottom" });
});
test("Basic jump to present when scrolling to outdated messages (DESC, chatter aside)", async () => {
patchUiSize({ size: SIZES.XXL });
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Demo User" });
for (let i = 0; i < 20; i++) {
pyEnv["mail.message"].create({
body: "Non Empty Body ".repeat(100),
message_type: "comment",
model: "res.partner",
res_id: partnerId,
});
}
await start();
await openFormView("res.partner", partnerId);
await contains(".o-mail-Message", { count: 20 });
await contains(".o-mail-Thread");
expect(document.querySelector(".o-mail-Chatter").scrollHeight).toBeGreaterThan(
PRESENT_VIEWPORT_THRESHOLD * document.querySelector(".o-mail-Chatter").clientHeight,
{ message: "should have enough scroll height to trigger jump to present" }
);
await contains(".o-mail-Chatter", { scroll: 0 });
await scroll(".o-mail-Chatter", "bottom");
await isInViewportOf("[title='Jump to Present']", ".o-mail-Thread");
await click("[title='Jump to Present']");
await contains("[title='Jump to Present']", { count: 0 });
await contains(".o-mail-Chatter", { scroll: 0 });
});
test("Basic jump to present when scrolling to outdated messages (DESC, chatter non-aside)", async () => {
patchUiSize({ size: SIZES.MD });
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Demo User" });
for (let i = 0; i < 20; i++) {
pyEnv["mail.message"].create({
body: "Non Empty Body ".repeat(100),
message_type: "comment",
model: "res.partner",
res_id: partnerId,
});
}
await start();
await openFormView("res.partner", partnerId);
await contains(".o-mail-Message", { count: 20 });
await contains(".o_content");
expect(document.querySelector(".o_content").scrollHeight).toBeGreaterThan(
PRESENT_VIEWPORT_THRESHOLD * document.querySelector(".o_content").clientHeight,
{ message: "should have enough scroll height to trigger jump to present" }
);
await contains(".o_content", { scroll: 0 });
await scroll(".o_content", "bottom");
await isInViewportOf("[title='Jump to Present']", ".o_content");
await click("[title='Jump to Present']");
await contains("[title='Jump to Present']", { count: 0 });
await contains(".o_content", { scroll: 0 });
});
test("Jump to old reply should prompt jump to present", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
const oldestMessageId = pyEnv["mail.message"].create({
body: "<p>Hello world!</p>",
model: "discuss.channel",
res_id: channelId,
});
for (let i = 0; i < 100; i++) {
pyEnv["mail.message"].create({
body: "<p>Non Empty Body</p>".repeat(100),
message_type: "comment",
model: "discuss.channel",
/**
* The first message following the oldest message should have it as its parent message
* so that the oldest message is inserted through the parent field during "load around"
* to have the coverage of this part of the code (in particular having parent message
* body being inserted with markup).
*/
parent_id: i === 0 ? oldestMessageId : undefined,
res_id: channelId,
});
}
const newestMessageId = pyEnv["mail.message"].create({
body: "Most Recent!",
model: "discuss.channel",
res_id: channelId,
parent_id: oldestMessageId,
});
const [selfMember] = pyEnv["discuss.channel.member"].search_read([
["partner_id", "=", serverState.partnerId],
["channel_id", "=", channelId],
]);
pyEnv["discuss.channel.member"].write([selfMember.id], {
new_message_separator: newestMessageId + 1,
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-Message", { count: 30 });
await click(".o-mail-MessageInReply .cursor-pointer");
await contains(".o-mail-Message", { count: 30 });
await contains(":nth-child(1 of .o-mail-Message)", { text: "Hello world!" });
await click("[title='Jump to Present']");
await contains("[title='Jump to Present']", { count: 0 });
await contains(".o-mail-Message", { count: 30 });
await contains(".o-mail-Thread", { scroll: "bottom" });
});
test("Jump to old reply should prompt jump to present (RPC small delay)", async () => {
// same test as before but with a small RPC delay
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
const oldestMessageId = pyEnv["mail.message"].create({
body: "<p>Hello world!</p>",
model: "discuss.channel",
res_id: channelId,
});
for (let i = 0; i < 100; i++) {
pyEnv["mail.message"].create({
body: "<p>Non Empty Body</p>".repeat(100),
message_type: "comment",
model: "discuss.channel",
/**
* The first message following the oldest message should have it as its parent message
* so that the oldest message is inserted through the parent field during "load around"
* to have the coverage of this part of the code (in particular having parent message
* body being inserted with markup).
*/
parent_id: i === 0 ? oldestMessageId : undefined,
res_id: channelId,
});
}
const newestMessageId = pyEnv["mail.message"].create({
body: "Most Recent!",
model: "discuss.channel",
res_id: channelId,
parent_id: oldestMessageId,
});
const [selfMember] = pyEnv["discuss.channel.member"].search_read([
["partner_id", "=", serverState.partnerId],
["channel_id", "=", channelId],
]);
pyEnv["discuss.channel.member"].write([selfMember.id], {
new_message_separator: newestMessageId + 1,
});
onRpcBefore("/discuss/channel/messages", tick); // small delay
await start();
await openDiscuss(channelId);
await contains(".o-mail-Message", { count: 30 });
await click(".o-mail-MessageInReply .cursor-pointer");
await click("[title='Jump to Present']");
await contains("[title='Jump to Present']", { count: 0 });
await contains(".o-mail-Thread", { scroll: "bottom" });
});
test("Post message when seeing old message should jump to present", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
const oldestMessageId = pyEnv["mail.message"].create({
body: "<p>Hello world!</p>",
model: "discuss.channel",
res_id: channelId,
});
for (let i = 0; i < 100; i++) {
pyEnv["mail.message"].create({
body: "<p>Non Empty Body</p>".repeat(100),
message_type: "comment",
model: "discuss.channel",
/**
* The first message following the oldest message should have it as its parent message
* so that the oldest message is inserted through the parent field during "load around"
* to have the coverage of this part of the code (in particular having parent message
* body being inserted with markup).
*/
parent_id: i === 0 ? oldestMessageId : undefined,
res_id: channelId,
});
}
pyEnv["mail.message"].create({
body: "Most Recent!",
model: "discuss.channel",
res_id: channelId,
parent_id: oldestMessageId,
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-Message", { count: 30 });
await click(".o-mail-MessageInReply .cursor-pointer");
await contains("[title='Jump to Present']");
await insertText(".o-mail-Composer-input", "Newly posted");
await press("Enter");
await contains("[title='Jump to Present']", { count: 0 });
await contains(".o-mail-Thread", { scroll: "bottom" });
await contains(".o-mail-Message-content", {
text: "Newly posted",
after: [".o-mail-Message-content", { text: "Most Recent!" }], // should load around present
});
});
test("when triggering jump to present, keeps showing old messages until recent ones are loaded", async () => {
// make scroll behavior instantaneous.
patchWithCleanup(Element.prototype, {
scrollIntoView() {
return super.scrollIntoView(true);
},
scrollTo(...args) {
if (typeof args[0] === "object" && args[0]?.behavior === "smooth") {
return super.scrollTo({ ...args[0], behavior: "instant" });
}
return super.scrollTo(...args);
},
});
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
for (let i = 0; i < 60; i++) {
pyEnv["mail.message"].create({
body: i === 0 ? "first-message" : "Non Empty Body ".repeat(100),
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
pinned_at: i === 0 ? "2020-02-12 08:30:00" : undefined,
});
}
let slowMessageFetchDeferred;
onRpcBefore("/discuss/channel/messages", async () => {
asyncStep("/discuss/channel/messages");
await slowMessageFetchDeferred;
});
await start();
await openDiscuss(channelId);
await waitForSteps(["/discuss/channel/messages"]);
await click("[title='Pinned Messages']");
await click(".o-discuss-PinnedMessagesPanel a[role='button']", { text: "Jump" });
await contains(".o-mail-Thread .o-mail-Message", { text: "first-message" });
await animationFrame();
slowMessageFetchDeferred = new Deferred();
await click("[title='Jump to Present']");
await animationFrame();
await waitForSteps(["/discuss/channel/messages"]);
await contains(".o-mail-Thread .o-mail-Message", { text: "first-message" });
slowMessageFetchDeferred.resolve();
await contains(".o-mail-Thread .o-mail-Message", { text: "first-message", count: 0 });
await contains(".o-mail-Thread", { scroll: "bottom" });
});
test("focus composer after jump to present", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
pyEnv["mail.message"].create(
[...Array(40).keys()].map((i) => ({
body: `<p>Non Empty Message ${i}</p>`,
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
}))
);
await start();
await openDiscuss(channelId);
await contains(".o-mail-Message", { count: 30 });
await contains(".o-mail-Composer.o-focused");
queryFirst(".o-mail-Composer-input").blur();
await contains(".o-mail-Composer.o-focused", { count: 0 });
await click("[title='Jump to Present']");
await contains(".o-mail-Composer.o-focused");
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,261 @@
import {
defineParams,
patchWithCleanup,
preloadBundle,
serverState,
} from "@web/../tests/web_test_helpers";
import {
click,
contains,
defineMailModels,
insertText,
openDiscuss,
scroll,
start,
startServer,
triggerHotkey,
} from "@mail/../tests/mail_test_helpers";
import { describe, getFixture, test } from "@odoo/hoot";
import { queryFirst } from "@odoo/hoot-dom";
describe.current.tags("desktop");
defineMailModels();
preloadBundle("web.assets_emoji");
test("emoji picker correctly handles translations with special characters", async () => {
defineParams({
translations: {
"Japanese “here” button": `Bouton "ici" japonais`,
"heavy dollar sign": `Symbole du dollar\nlourd`,
},
});
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "" });
await start();
await openDiscuss(channelId);
await click("button[title='Add Emojis']");
await insertText(".o-EmojiPicker-search input", "ici");
await contains(`.o-Emoji[title='Bouton "ici" japonais']`);
await insertText(".o-EmojiPicker-search input", "dollar", { replace: true });
await contains(`.o-Emoji[title*='Symbole du dollar']`);
});
test("search emoji from keywords", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "" });
await start();
await openDiscuss(channelId);
await click("button[title='Add Emojis']");
await insertText("input[placeholder='Search emoji']", "mexican");
await contains(".o-Emoji", { text: "🌮" });
await insertText(".o-EmojiPicker-search input", "9", { replace: true });
await contains(".o-Emoji:eq(0)", { text: "🕘" });
await contains(".o-Emoji:eq(1)", { text: "🕤" });
await contains(".o-Emoji:eq(2)", { text: "9⃣" });
});
test("search emoji from keywords should be case insensitive", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "" });
await start();
await openDiscuss(channelId);
await click("button[title='Add Emojis']");
await insertText("input[placeholder='Search emoji']", "ok");
await contains(".o-Emoji", { text: "🆗" }); // all search terms are uppercase OK
});
test("search emoji from keywords with special regex character", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "" });
await start();
await openDiscuss(channelId);
await click("button[title='Add Emojis']");
await insertText("input[placeholder='Search emoji']", "(blood");
await contains(".o-Emoji", { text: "🆎" });
});
test("updating search emoji should scroll top", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "" });
await start();
await openDiscuss(channelId);
await click("button[title='Add Emojis']");
await contains(".o-EmojiPicker-content", { scroll: 0 });
await scroll(".o-EmojiPicker-content", 150);
await insertText("input[placeholder='Search emoji']", "m");
await contains(".o-EmojiPicker-content", { scroll: 0 });
});
test("Press Escape in emoji picker closes the emoji picker", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "" });
await start();
await openDiscuss(channelId);
await click("button[title='Add Emojis']");
triggerHotkey("Escape");
await contains(".o-EmojiPicker", { count: 0 });
});
test("Basic keyboard navigation", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "" });
await start();
await openDiscuss(channelId);
await contains(".o-mail-Composer-input:focus"); // as to ensure no race condition with auto-focus of emoji picker
await click("button[title='Add Emojis']");
await contains(".o-Emoji[data-index='0'].o-active");
// detect amount of emojis per row for navigation
const emojis = Array.from(
getFixture().querySelectorAll(".o-EmojiPicker-category[data-category='1'] ~ .o-Emoji")
);
const baseOffset = emojis[0].offsetTop;
const breakIndex = emojis.findIndex((item) => item.offsetTop > baseOffset);
const EMOJI_PER_ROW = breakIndex === -1 ? emojis.length : breakIndex;
triggerHotkey("ArrowRight");
await contains(".o-EmojiPicker-content .o-Emoji[data-index='1'].o-active");
triggerHotkey("ArrowDown");
await contains(`.o-EmojiPicker-content .o-Emoji[data-index='${EMOJI_PER_ROW + 1}'].o-active`);
triggerHotkey("ArrowLeft");
await contains(`.o-EmojiPicker-content .o-Emoji[data-index='${EMOJI_PER_ROW}'].o-active`);
triggerHotkey("ArrowUp");
await contains(".o-EmojiPicker-content .o-Emoji[data-index='0'].o-active");
const { codepoints } = queryFirst(
".o-EmojiPicker-content .o-Emoji[data-index='0'].o-active"
).dataset;
triggerHotkey("Enter");
await contains(".o-EmojiPicker", { count: 0 });
await contains(".o-mail-Composer-input", { value: codepoints });
});
test("recent category (basic)", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "" });
await start();
await openDiscuss(channelId);
await click("button[title='Add Emojis']");
await contains(".o-EmojiPicker-navbar [title='Frequently used']", { count: 0 });
await click(".o-EmojiPicker-content .o-Emoji", { text: "😀" });
await click("button[title='Add Emojis']");
await contains(".o-EmojiPicker-navbar [title='Frequently used']");
await contains(".o-Emoji", {
text: "😀",
after: ["span", { textContent: "Frequently used" }],
before: ["span", { textContent: "Smileys & Emotion" }],
});
});
test("search emojis prioritize frequently used emojis", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "" });
await start();
await openDiscuss(channelId);
await click("button[title='Add Emojis']");
await contains(".o-EmojiPicker-navbar [title='Frequently used']", { count: 0 });
await click(".o-EmojiPicker-content .o-Emoji", { text: "🤥" });
await click("button[title='Add Emojis']");
await contains(".o-EmojiPicker-navbar [title='Frequently used']");
await insertText("input[placeholder='Search emoji']", "lie");
await contains(".o-EmojiPicker-sectionIcon", { count: 0 }); // await search performed
await contains(".o-EmojiPicker-content .o-Emoji:eq(0)", { text: "🤥" });
});
test("search matches only frequently used emojis", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "" });
await start();
await openDiscuss(channelId);
await click("button[title='Add Emojis']");
await contains(".o-EmojiPicker-navbar [title='Frequently used']", { count: 0 });
await click(".o-EmojiPicker-content .o-Emoji", { text: "🥦" });
await click("button[title='Add Emojis']");
await contains(".o-EmojiPicker-navbar [title='Frequently used']");
await insertText(".o-EmojiPicker-search input", "brocoli");
await contains(".o-EmojiPicker-sectionIcon", { count: 0 }); // await search performed
await contains(".o-EmojiPicker-content .o-Emoji:eq(0)", { text: "🥦" });
await contains(".o-EmojiPicker-content .o-Emoji", { count: 1 });
await contains(".o-EmojiPicker-content:has(:text('No emojis match your search'))", {
count: 0,
});
await insertText(".o-EmojiPicker-search input", "2");
await contains(".o-EmojiPicker-content:has(:text('No emojis match your search'))");
});
test("emoji usage amount orders frequent emojis", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "" });
await start();
await openDiscuss(channelId);
await click("button[title='Add Emojis']");
await click(".o-EmojiPicker-content .o-Emoji", { text: "😀" });
await click("button[title='Add Emojis']");
await click(".o-EmojiPicker-content .o-Emoji", { text: "👽" });
await click("button[title='Add Emojis']");
await click(".o-EmojiPicker-content .o-Emoji", { text: "👽" });
await click("button[title='Add Emojis']");
await contains(".o-Emoji", {
text: "👽",
after: ["span", { textContent: "Frequently used" }],
before: [
".o-Emoji",
{
text: "😀",
after: ["span", { textContent: "Frequently used" }],
before: ["span", { textContent: "Smileys & Emotion" }],
},
],
});
});
test("first category should be highlighted by default", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "" });
await start();
await openDiscuss(channelId);
await click("button[title='Add Emojis']");
await contains(".o-EmojiPicker-navbar :nth-child(1 of .o-Emoji).o-active");
});
test("selecting an emoji while holding down the Shift key prevents the emoji picker from closing", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "" });
await start();
await openDiscuss(channelId);
await click("button[title='Add Emojis']");
await click(".o-EmojiPicker-content .o-Emoji", { shiftKey: true, text: "👺" });
await contains(".o-EmojiPicker-navbar [title='Frequently used']");
await contains(".o-EmojiPicker");
await contains(".o-mail-Composer-input", { value: "👺" });
});
test("shortcodes shown in emoji title in message", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "" });
pyEnv["mail.message"].create({
res_id: channelId,
model: "discuss.channel",
body: "💑😇",
author_id: serverState.partnerId,
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-Message", { text: "💑😇" });
await contains(".o-mail-Message span[title=':couple_with_heart:']", { text: "💑" });
await contains(".o-mail-Message span[title=':innocent: :halo:']", { text: "😇" });
});
test("Emoji picker shows failure to load emojis", async () => {
// Simulate failure to load emojis
patchWithCleanup(odoo.loader.modules.get("@web/core/emoji_picker/emoji_data"), {
getEmojis() {
return [];
},
});
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "" });
await start();
await openDiscuss(channelId);
await click("button[title='Add Emojis']");
await contains(".o-EmojiPicker", { text: "😵💫Failed to load emojis..." });
});

View file

@ -0,0 +1,10 @@
import { expect, test } from "@odoo/hoot";
import { formatText } from "@mail/js/emojis_mixin";
test("Emoji formatter handles compound emojis", () => {
const testString = "<p>👩🏿test👩🏿👩t👩</p>";
const expectedString =
"&lt;p&gt;<span class='o_mail_emoji'>👩🏿</span>test<span class='o_mail_emoji'>👩🏿👩</span>t<span class='o_mail_emoji'>👩</span>&lt;/p&gt;";
expect(formatText(testString).toString()).toBe(expectedString);
});

View file

@ -0,0 +1,339 @@
import {
SIZES,
click,
contains,
defineMailModels,
focus,
insertText,
openDiscuss,
openFormView,
patchUiSize,
scroll,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { describe, expect, test } from "@odoo/hoot";
import {
asyncStep,
getService,
onRpc,
patchWithCleanup,
preloadBundle,
waitForSteps,
} from "@web/../tests/web_test_helpers";
import { GifPicker } from "@mail/discuss/gif_picker/common/gif_picker";
import { animationFrame, queryFirst } from "@odoo/hoot-dom";
describe.current.tags("desktop");
defineMailModels();
preloadBundle("web.assets_emoji");
let gifId = 0;
const gifFactory = (count = 1, options = {}) => {
const gifs = [];
for (let i = 0; i < count; i++) {
gifs.push({
id: `${gifId}`,
title: "",
media_formats: {
tinygif: {
url: options.url || "https://media.tenor.com/np49Y1vrJO8AAAAM/crying-cry.gif",
duration: 0,
preview: "",
dims: [220, 190],
size: 1007885,
},
},
created: 1654414453.782169,
content_description: "Cry GIF",
itemurl: "https://tenor.com/view/cry-gif-25866484",
url: "https://tenor.com/bUHdw.gif",
tags: ["cry"],
flags: [],
hasaudio: false,
});
gifId++;
}
return gifs;
};
const rpc = {
search: {
results: gifFactory(2),
next: "CAgQpIGj_8WN_gIaHgoKAD-_xMQ20dMU_xIQ1MVHUnSAQxC98Y6VAAAAADAI",
},
categories: {
locale: "en",
tags: [
{
searchterm: "cry",
path: "/v2/search?q=cry&locale=en&component=categories&contentfilter=low",
image: "https://media.tenor.com/6uIlQAHIkNoAAAAM/cry.gif",
name: "#cry",
},
{
searchterm: "yes",
path: "/v2/search?q=yes&locale=en&component=categories&contentfilter=low",
image: "https://media.tenor.com/UVmpVqlpVhQAAAAM/yess-yes.gif",
name: "#yes",
},
{
searchterm: "no",
path: "/v2/search?q=no&locale=en&component=categories&contentfilter=low",
image: "https://media.tenor.com/aeswYw-86k8AAAAM/no-nooo.gif",
name: "#no",
},
{
searchterm: "lol",
path: "/v2/search?q=lol&locale=en&component=categories&contentfilter=low",
image: "https://media.tenor.com/BiseY2UXovAAAAAM/lmfao-laughing.gif",
name: "#lol",
},
],
},
};
test("composer should display a GIF button", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "" });
await start();
await openDiscuss(channelId);
await contains("button[title='Add GIFs']");
});
test("Composer GIF button should open the GIF picker (discuss app)", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "" });
await start();
await openDiscuss(channelId);
await click("button[title='Add GIFs']");
await contains(".o-discuss-GifPicker");
});
test("Composer GIF button should open the GIF picker (chat window)", async () => {
const pyEnv = await startServer();
pyEnv["discuss.channel"].create({ name: "General" });
await start();
await click(".o_menu_systray i[aria-label='Messages']");
await click(".o-mail-NotificationItem:contains('General')");
await click(".o-mail-ChatWindow .o-mail-Composer [title='More Actions']");
await click(".o-dropdown-item:contains('Add GIFs')");
await contains(".o-discuss-GifPicker");
});
test("Not loading of GIF categories when feature is not available", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "" });
let isFeatureEnabled = true;
onRpc("/discuss/gif/categories", () => {
asyncStep("/discuss/gif/categories");
if (isFeatureEnabled) {
return rpc.categories;
}
});
await start();
await openDiscuss(channelId);
const store = getService("mail.store");
store.hasGifPickerFeature = false;
isFeatureEnabled = false;
await click("button[title='Add GIFs']");
await contains(".o-discuss-GifPicker");
await animationFrame();
expect.verifySteps([]); // no "/discuss/gif/categories"
await click("button[title='Add GIFs']");
await contains(".o-discuss-GifPicker", { count: 0 });
store.hasGifPickerFeature = true;
isFeatureEnabled = true;
await click("button[title='Add GIFs']");
await contains(".o-discuss-GifPicker");
await waitForSteps(["/discuss/gif/categories"]);
});
test("Searching for a GIF", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "" });
onRpc("/discuss/gif/search", () => rpc.search);
await start();
await openDiscuss(channelId);
await click("button[title='Add GIFs']");
await insertText("input[placeholder='Search for a GIF']", "search");
await contains("i[aria-label='back']");
await contains(".o-discuss-Gif", { count: 2 });
});
test("Open a GIF category trigger the search for the category", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "" });
onRpc("/discuss/gif/categories", () => rpc.categories);
onRpc("/discuss/gif/search", () => rpc.search);
await start();
await openDiscuss(channelId);
await click("button[title='Add GIFs']");
await click("img[data-src='https://media.tenor.com/6uIlQAHIkNoAAAAM/cry.gif']");
await contains(".o-discuss-Gif", { count: 2 });
await contains("input[placeholder='Search for a GIF']", { value: "cry" });
});
test("Can have GIF categories with same name", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "" });
onRpc("/discuss/gif/categories", () => ({
locale: "en",
tags: [
{
searchterm: "duplicate",
path: "/v2/search?q=duplicate&locale=en&component=categories&contentfilter=low",
image: "https://media.tenor.com/BiseY2UXovAAAAAM/duplicate.gif",
name: "#duplicate",
},
{
searchterm: "duplicate",
path: "/v2/search?q=duplicate&locale=en&component=categories&contentfilter=low",
image: "https://media.tenor.com/BiseY2UXovAAAAAM/duplicate.gif",
name: "#duplicate",
},
],
}));
onRpc("/discuss/gif/search", () => rpc.search);
await start();
await openDiscuss(channelId);
await click("button[title='Add GIFs']");
await contains("img[data-src='https://media.tenor.com/BiseY2UXovAAAAAM/duplicate.gif']", {
count: 2,
});
});
test("Reopen GIF category list when going back", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "" });
onRpc("/discuss/gif/categories", () => rpc.categories);
onRpc("/discuss/gif/search", () => rpc.search);
await start();
await openDiscuss(channelId);
await click("button[title='Add GIFs']");
await click("img[data-src='https://media.tenor.com/6uIlQAHIkNoAAAAM/cry.gif']");
await click("i[aria-label='back']");
await contains(".o-discuss-GifPicker div[aria-label='list']");
});
test("Add GIF to favorite", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "" });
onRpc("/discuss/gif/categories", () => rpc.categories);
onRpc("/discuss/gif/search", () => rpc.search);
await start();
await openDiscuss(channelId);
await click("button[title='Add GIFs']");
await click("img[data-src='https://media.tenor.com/6uIlQAHIkNoAAAAM/cry.gif']");
await click(":nth-child(1 of div) > .o-discuss-Gif .fa-star-o");
await contains(".o-discuss-Gif .fa-star");
await click("i[aria-label='back']");
await click(".o-discuss-GifPicker div[aria-label='list-item']", { text: "Favorites" });
await contains(".o-discuss-Gif");
});
test("Chatter should not have the GIF button", async () => {
const pyEnv = await startServer();
await start();
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
await openFormView("res.partner", partnerId);
await click("button", { text: "Log note" });
await contains("button[title='Add GIFs']", { count: 0 });
});
test("Composer GIF button should open the GIF picker keyboard in footer", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
patchUiSize({ size: SIZES.SM });
await start();
await openDiscuss(channelId);
await click("button[title='More Actions']");
await click(".dropdown-item:contains('Add GIFs')");
await contains(".o-mail-Composer-footer .o-discuss-GifPicker");
});
test("Searching for a GIF with a failling RPC should display an error", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "" });
onRpc("/discuss/gif/categories", () => rpc.categories);
onRpc("/discuss/gif/search", () => {
throw new Error("Rpc failed");
});
await start();
await openDiscuss(channelId);
await click("button[title='Add GIFs']");
await insertText("input[placeholder='Search for a GIF']", "search");
await contains(".o-discuss-GifPicker-error");
});
test("Scrolling at the bottom should trigger the search to load more gif, even after visiting the favorite.", async () => {
patchWithCleanup(GifPicker.prototype, {
get style() {
return "width: 200px;height: 200px;background: #000";
},
});
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "" });
onRpc("/discuss/gif/categories", () => rpc.categories);
onRpc("/discuss/gif/search", () => {
const _rpc = rpc.search;
_rpc.results = gifFactory(4);
return _rpc;
});
await start();
await openDiscuss(channelId);
await click("button[title='Add GIFs']");
// gif picker quires extra delay before click (to give time to load initial state)
await contains(".o-discuss-GifPicker");
await click(".o-discuss-GifPicker div[aria-label='list-item']", { text: "Favorites" });
await click("i[aria-label='back']");
await click("img[data-src='https://media.tenor.com/6uIlQAHIkNoAAAAM/cry.gif']");
await contains(".o-discuss-Gif", { count: 4 });
await scroll(".o-discuss-GifPicker-content", "bottom");
await contains(".o-discuss-Gif", { count: 8 });
});
test("Pause GIF when thread is not focused", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "" });
onRpc("/discuss/gif/categories", () => rpc.categories);
onRpc("/discuss/gif/search", () => rpc.search);
await start();
await openDiscuss(channelId);
await click("button[title='Add GIFs']");
await click("img[data-src='https://media.tenor.com/6uIlQAHIkNoAAAAM/cry.gif']");
await click("img[data-src='https://media.tenor.com/np49Y1vrJO8AAAAM/crying-cry.gif']:eq(0)");
await contains(".o-mail-LinkPreviewImage");
queryFirst(".o-mail-Thread").blur();
await contains(".o-mail-LinkPreviewImage img[data-paused]");
await focus(".o-mail-Thread");
await contains(".o-mail-LinkPreviewImage img:not([data-paused])");
});
test("Show help when no favorite GIF", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "" });
onRpc("/discuss/gif/categories", () => rpc.categories);
await start();
await openDiscuss(channelId);
await click("button[title='Add GIFs']");
// gif picker quires extra delay before click (to give time to load initial state)
await contains(".o-discuss-GifPicker");
await click(".o-discuss-GifPicker div[aria-label='list-item']", { text: "Favorites" });
await contains("span", { text: "So uhh... maybe go favorite some GIFs?" });
});
test("Clicking GIF preview does not raise an error", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "" });
onRpc("/discuss/gif/categories", () => rpc.categories);
onRpc("/discuss/gif/search", () => rpc.search);
await start();
await openDiscuss(channelId);
await click("button[title='Add GIFs']");
await click("img[data-src='https://media.tenor.com/6uIlQAHIkNoAAAAM/cry.gif']");
await click("img[data-src='https://media.tenor.com/np49Y1vrJO8AAAAM/crying-cry.gif']:eq(0)");
await click(".o-mail-LinkPreviewImage img");
await contains(".o-mail-Message");
});

View file

@ -1,111 +0,0 @@
/** @odoo-module **/
import { patch } from "@web/core/utils/patch";
import { MockServer } from "@web/../tests/helpers/mock_server";
patch(MockServer.prototype, 'mail/controllers/discuss', {
/**
* @override
*/
async _performRPC(route, args) {
if (route === '/mail/channel/notify_typing') {
const id = args.channel_id;
const is_typing = args.is_typing;
const context = args.context;
return this._mockRouteMailChannelNotifyTyping(id, is_typing, context);
}
if (route === '/mail/channel/ping') {
return;
}
if (route === '/mail/rtc/channel/join_call') {
return this._mockRouteMailRtcChannelJoinCall(args.channel_id, args.check_rtc_session_ids);
}
if (route === '/mail/rtc/channel/leave_call') {
return this._mockRouteMailRtcChannelLeaveCall(args.channel_id);
}
if (route === '/mail/rtc/session/update_and_broadcast') {
return;
}
return this._super(route, args);
},
/**
* Simulates the `/mail/channel/notify_typing` route.
*
* @private
* @param {integer} channel_id
* @param {integer} limit
* @param {Object} [context={}]
*/
async _mockRouteMailChannelNotifyTyping(channel_id, is_typing, context = {}) {
const partnerId = context.mockedPartnerId || this.currentPartnerId;
const [memberOfCurrentUser] = this.getRecords('mail.channel.member', [['channel_id', '=', channel_id], ['partner_id', '=', partnerId]]);
if (!memberOfCurrentUser) {
return;
}
this._mockMailChannelMember_NotifyTyping([memberOfCurrentUser.id], is_typing);
},
/**
* Simulates the `/mail/rtc/channel/join_call` route.
*
* @private
* @param {integer} channel_id
* @returns {integer[]} [check_rtc_session_ids]
*/
async _mockRouteMailRtcChannelJoinCall(channel_id, check_rtc_session_ids = []) {
const [currentChannelMember] = this.getRecords('mail.channel.member', [
['channel_id', '=', channel_id],
['partner_id', '=', this.currentPartnerId],
]);
const sessionId = this.pyEnv['mail.channel.rtc.session'].create({
channel_member_id: currentChannelMember.id,
channel_id, // on the server, this is a related field from channel_member_id and not explicitly set
});
const channelMembers = this.getRecords('mail.channel.member', [['channel_id', '=', channel_id]]);
const rtcSessions = this.getRecords('mail.channel.rtc.session', [
['channel_member_id', 'in', channelMembers.map(channelMember => channelMember.id)],
]);
return {
'iceServers': false,
'rtcSessions': [
['insert', rtcSessions.map(rtcSession => this._mockMailChannelRtcSession_MailChannelRtcSessionFormat(rtcSession.id))],
],
'sessionId': sessionId,
};
},
/**
* Simulates the `/mail/rtc/channel/leave_call` route.
*
* @private
* @param {integer} channelId
*/
async _mockRouteMailRtcChannelLeaveCall(channel_id) {
const channelMembers = this.getRecords('mail.channel.member', [['channel_id', '=', channel_id]]);
const rtcSessions = this.getRecords('mail.channel.rtc.session', [
['channel_member_id', 'in', channelMembers.map(channelMember => channelMember.id)],
]);
const notifications = [];
const channelInfo = this._mockMailChannelRtcSession_MailChannelRtcSessionFormatByChannel(rtcSessions.map(rtcSession => rtcSession.id));
for (const [channelId, sessionsData] of Object.entries(channelInfo)) {
const notificationRtcSessions = sessionsData.map((sessionsDataPoint) => {
return { 'id': sessionsDataPoint.id };
});
notifications.push([
channelId,
'mail.channel/rtc_sessions_update',
{
'id': Number(channelId), // JS object keys are strings, but the type from the server is number
'rtcSessions': [['insert-and-unlink', notificationRtcSessions]],
}
]);
}
for (const rtcSession of rtcSessions) {
const target = rtcSession.guest_id || rtcSession.partner_id;
notifications.push([
target,
'mail.channel.rtc.session/ended',
{ 'sessionId': rtcSession.id },
]);
}
this.pyEnv['bus.bus']._sendmany(notifications);
},
});

View file

@ -1,25 +0,0 @@
/** @odoo-module **/
import { patch } from "@web/core/utils/patch";
import { MockServer } from "@web/../tests/helpers/mock_server";
// ensure bus override is applied first.
import "@bus/../tests/helpers/mock_server";
patch(MockServer.prototype, 'mail/models/ir_websocket', {
/**
* Simulates `_get_im_status` on `ir.websocket`.
*
* @param {Object} imStatusIdsByModel
* @param {Number[]|undefined} mail.guest ids of mail.guest whose im_status
* should be monitored.
*/
_mockIrWebsocket__getImStatus(imStatusIdsByModel) {
const imStatus = this._super(imStatusIdsByModel);
const { 'mail.guest': guestIds } = imStatusIdsByModel;
if (guestIds) {
imStatus['guests'] = this.pyEnv['mail.guest'].searchRead([['id', 'in', guestIds]], { context: { 'active_test': false }, fields: ['im_status'] });
}
return imStatus;
},
});

View file

@ -1,73 +0,0 @@
/** @odoo-module **/
import { patch } from "@web/core/utils/patch";
import { MockServer } from "@web/../tests/helpers/mock_server";
patch(MockServer.prototype, 'mail/models/mail_channel_member', {
/**
* Simulates `notify_typing` on `mail.channel.member`.
*
* @private
* @param {integer[]} ids
* @param {boolean} is_typing
*/
_mockMailChannelMember_NotifyTyping(ids, is_typing) {
const members = this.getRecords('mail.channel.member', [['id', 'in', ids]]);
const notifications = [];
for (const member of members) {
const [channel] = this.getRecords('mail.channel', [['id', '=', member.channel_id]]);
const [data] = this._mockMailChannelMember_MailChannelMemberFormat([member.id]);
Object.assign(data, {
'isTyping': is_typing,
});
notifications.push([channel, 'mail.channel.member/typing_status', data]);
notifications.push([channel.uuid, 'mail.channel.member/typing_status', data]);
}
this.pyEnv['bus.bus']._sendmany(notifications);
},
/**
* Simulates `_mail_channel_member_format` on `mail.channel.member`.
*
* @private
* @param {integer[]} ids
* @returns {Object[]}
*/
_mockMailChannelMember_MailChannelMemberFormat(ids) {
const members = this.getRecords('mail.channel.member', [['id', 'in', ids]]);
const dataList = [];
for (const member of members) {
let persona;
if (member.partner_id) {
persona = { 'partner': this._mockMailChannelMember_GetPartnerData([member.id]) };
}
if (member.guest_id) {
const [guest] = this.getRecords('mail.guest', [['id', '=', member.guest_id]]);
persona = {
'guest': {
'id': guest.id,
'im_status': guest.im_status,
'name': guest.name,
},
};
}
const data = {
'channel': { 'id': member.channel_id },
'id': member.id,
'persona': persona,
};
dataList.push(data);
}
return dataList;
},
/**
* Simulates `_get_partner_data` on `mail.channel.member`.
*
* @private
* @param {integer[]} ids
* @returns {Object}
*/
_mockMailChannelMember_GetPartnerData(ids) {
const [member] = this.getRecords('mail.channel.member', [['id', 'in', ids]]);
return this._mockResPartnerMailPartnerFormat([member.partner_id]).get(member.partner_id);
},
});

View file

@ -1,43 +0,0 @@
/** @odoo-module **/
import { patch } from "@web/core/utils/patch";
import { MockServer } from "@web/../tests/helpers/mock_server";
patch(MockServer.prototype, 'mail/models/mail_channel_rtc_session', {
/**
* Simulates `_mail_rtc_session_format` on `mail.channel.rtc.session`.
*
* @private
* @param {integer} id
* @returns {Object}
*/
_mockMailChannelRtcSession_MailChannelRtcSessionFormat(id) {
const [rtcSession] = this.getRecords('mail.channel.rtc.session', [['id', '=', id]]);
return {
'id': rtcSession.id,
'channelMember': this._mockMailChannelMember_MailChannelMemberFormat([rtcSession.channel_member_id])[0],
'isCameraOn': rtcSession.is_camera_on,
'isDeaf': rtcSession.is_deaf,
'isSelfMuted': rtcSession.is_self_muted,
'isScreenSharingOn': rtcSession.is_screen_sharing_on,
};
},
/**
* Simulates `_mail_rtc_session_format` on `mail.channel.rtc.session`.
*
* @private
* @param {integer[]} ids
* @returns {Object}
*/
_mockMailChannelRtcSession_MailChannelRtcSessionFormatByChannel(ids) {
const rtcSessions = this.getRecords('mail.channel.rtc.session', [['id', 'in', ids]]);
const data = {};
for (const rtcSession of rtcSessions) {
if (!data[rtcSession.channel_id]) {
data[rtcSession.channel_id] = [];
}
data[rtcSession.channel_id].push(this._mockMailChannelRtcSession_MailChannelRtcSessionFormat(rtcSession.id));
}
return data;
}
});

View file

@ -1,104 +0,0 @@
/** @odoo-module **/
import { TEST_GROUP_IDS, TEST_USER_IDS } from '@bus/../tests/helpers/test_constants';
import {
addFakeModel,
addModelNamesToFetch,
insertModelFields,
insertRecords
} from '@bus/../tests/helpers/model_definitions_helpers';
//--------------------------------------------------------------------------
// Models
//--------------------------------------------------------------------------
addModelNamesToFetch([
'mail.activity', 'mail.activity.type', 'mail.channel', 'mail.channel.member',
'mail.channel.rtc.session', 'mail.followers', 'mail.guest', 'mail.link.preview', 'mail.message',
'mail.message.subtype', 'mail.notification', 'mail.shortcode', 'mail.template',
'mail.tracking.value', 'res.users.settings', 'res.users.settings.volumes'
]);
addFakeModel('res.fake', {
message_ids: { string: 'Messages', type: 'one2many', relation: 'mail.message' },
activity_ids: { string: "Activities", type: 'one2many', relation: 'mail.activity' },
email_cc: { type: 'char' },
partner_ids: { relation: 'res.partner', string: "Related partners", type: 'one2many' },
});
addFakeModel('m2x.avatar.user', {
user_id: { type: 'many2one', relation: 'res.users' },
user_ids: { type: 'many2many', relation: 'res.users' },
});
//--------------------------------------------------------------------------
// Insertion of fields
//--------------------------------------------------------------------------
insertModelFields('mail.activity', {
chaining_type: { default: 'suggest' },
});
insertModelFields('mail.channel', {
author_id: {
default() {
return this.currentPartnerId;
},
},
avatarCacheKey: { string: "Avatar Cache Key", type: "datetime" },
channel_member_ids: {
default() {
return [[0, 0, { partner_id: this.currentPartnerId }]];
},
},
channel_type: { default: 'channel' },
group_based_subscription: { string: "Group based subscription", type: "boolean" },
group_public_id: {
default() {
return TEST_GROUP_IDS.groupUserId;
},
},
uuid: { default: () => _.uniqueId('mail.channel_uuid-') },
});
insertModelFields('mail.channel.member', {
fold_state: { default: 'open' },
is_pinned: { default: true },
message_unread_counter: { default: 0 },
});
insertModelFields('mail.message', {
author_id: { default: TEST_USER_IDS.currentPartnerId },
history_partner_ids: { relation: 'res.partner', string: "Partners with History", type: 'many2many' },
is_discussion: { string: 'Discussion', type: 'boolean' },
is_note: { string: "Discussion", type: 'boolean' },
is_notification: { string: "Note", type: 'boolean' },
needaction_partner_ids: { relation: 'res.partner', string: "Partners with Need Action", type: 'many2many' },
res_model_name: { string: "Res Model Name", type: 'char' },
});
insertModelFields('mail.message.subtype', {
subtype_xmlid: { type: 'char' },
});
insertModelFields('mail.tracking.value', {
changed_field: { string: 'Changed field', type: 'char' },
new_value: { string: 'New value', type: 'char' },
old_value: { string: 'Old value', type: 'char' },
});
insertModelFields('res.users.settings', {
is_discuss_sidebar_category_channel_open: { default: true },
is_discuss_sidebar_category_chat_open: { default: true },
});
//--------------------------------------------------------------------------
// Insertion of records
//--------------------------------------------------------------------------
insertRecords('mail.activity.type', [
{ icon: 'fa-envelope', id: 1, name: "Email" },
{ icon: 'fa-upload', id: 28, name: "Upload Document" },
]);
insertRecords('mail.message.subtype', [
{ default: false, internal: true, name: "Activities", sequence: 90, subtype_xmlid: 'mail.mt_activities' },
{
default: false, internal: true, name: "Note", sequence: 100, subtype_xmlid: 'mail.mt_note',
track_recipients: true
},
{ name: "Discussions", sequence: 0, subtype_xmlid: 'mail.mt_comment', track_recipients: true },
]);

View file

@ -1,106 +0,0 @@
/** @odoo-module **/
import { browser } from '@web/core/browser/browser';
import { MEDIAS_BREAKPOINTS, SIZES, uiService } from '@web/core/ui/ui_service';
import { patchWithCleanup } from "@web/../tests/helpers/utils";
import config from 'web.config';
/**
* Return the width corresponding to the given size. If an upper and lower bound
* are defined, returns the lower bound: this is an arbitrary choice that should
* not impact anything. A test should pass the `width` parameter instead of `size`
* if it needs a specific width to be set.
*
* @param {number} size
* @returns {number} The width corresponding to the given size.
*/
function getWidthFromSize(size) {
const { minWidth, maxWidth } = MEDIAS_BREAKPOINTS[size];
return minWidth ? minWidth : maxWidth;
}
/**
* Return the size corresponding to the given width.
*
* @param {number} width
* @returns {number} The size corresponding to the given width.
*/
function getSizeFromWidth(width) {
return MEDIAS_BREAKPOINTS.findIndex(({ minWidth, maxWidth }) => {
if (!maxWidth) {
return width >= minWidth;
}
if (!minWidth) {
return width <= maxWidth;
}
return width >= minWidth && width <= maxWidth;
});
}
/**
* Patch legacy objects referring to the ui size. This function must be removed
* when the wowl env will be available in the form_renderer (currently the form
* renderer relies on config). This will impact env.browser.innerWidth,
* env.device.isMobile and config.device.{size_class/isMobile}.
*
* @param {number} size
* @param {number} width
*/
function legacyPatchUiSize(height, size, width) {
const legacyEnv = owl.Component.env;
patchWithCleanup(legacyEnv, {
browser: {
...legacyEnv.browser,
innerWidth: width,
innerHeight: height || browser.innerHeight,
},
device: {
...legacyEnv.device,
isMobile: size <= SIZES.SM,
}
});
patchWithCleanup(config, {
device: {
...config.device,
size_class: size,
isMobile: size <= SIZES.SM,
},
});
}
/**
* Adjust ui size either from given size (mapped to window breakpoints) or
* width. This will impact not only config.device.{size_class/isMobile} but
* uiService.{isSmall/size}, (wowl/legacy) browser.innerWidth, (wowl)
* env.isSmall and (legacy) env.device.isMobile. When a size is given, the browser
* width is set according to the breakpoints that are used by the webClient.
*
* @param {Object} params parameters to configure the ui size.
* @param {number|undefined} [params.size]
* @param {number|undefined} [params.width]
* @param {number|undefined} [params.height]
*/
function patchUiSize({ height, size, width }) {
if (!size && !width || size && width) {
throw new Error('Either size or width must be given to the patchUiSize function');
}
size = size === undefined ? getSizeFromWidth(width) : size;
width = width || getWidthFromSize(size);
patchWithCleanup(browser, {
innerWidth: width,
innerHeight: height || browser.innerHeight,
});
patchWithCleanup(uiService, {
getSize() {
return size;
},
});
legacyPatchUiSize(height, size, width);
}
export {
patchUiSize,
SIZES
};

View file

@ -1,398 +0,0 @@
/** @odoo-module **/
import { getPyEnv, startServer } from '@bus/../tests/helpers/mock_python_environment';
import { nextTick } from '@mail/utils/utils';
import { getAdvanceTime } from '@mail/../tests/helpers/time_control';
import { getWebClientReady } from '@mail/../tests/helpers/webclient_setup';
import { wowlServicesSymbol } from "@web/legacy/utils";
import { registerCleanup } from "@web/../tests/helpers/cleanup";
import { getFixture, makeDeferred, patchWithCleanup } from "@web/../tests/helpers/utils";
import { doAction, getActionManagerServerData } from "@web/../tests/webclient/helpers";
const { App, EventBus } = owl;
const { afterNextRender } = App;
//------------------------------------------------------------------------------
// Private
//------------------------------------------------------------------------------
/**
* Create a fake object 'dataTransfer', linked to some files,
* which is passed to drag and drop events.
*
* @param {Object[]} files
* @returns {Object}
*/
function _createFakeDataTransfer(files) {
return {
dropEffect: 'all',
effectAllowed: 'all',
files,
items: [],
types: ['Files'],
};
}
//------------------------------------------------------------------------------
// Public: rendering timers
//------------------------------------------------------------------------------
/**
* Returns a promise resolved at the next animation frame.
*
* @returns {Promise}
*/
function nextAnimationFrame() {
return new Promise(function (resolve) {
setTimeout(() => requestAnimationFrame(() => resolve()));
});
}
//------------------------------------------------------------------------------
// Public: test lifecycle
//------------------------------------------------------------------------------
function getAfterEvent({ messagingBus }) {
/**
* Returns a promise resolved after the expected event is received.
*
* @param {Object} param0
* @param {string} param0.eventName event to wait
* @param {function} param0.func function which, when called, is expected to
* trigger the event
* @param {string} [param0.message] assertion message
* @param {function} [param0.predicate] predicate called with event data.
* If not provided, only the event name has to match.
* @param {number} [param0.timeoutDelay=5000] how long to wait at most in ms
* @returns {Promise}
*/
return async function afterEvent({ eventName, func, message, predicate, timeoutDelay = 5000 }) {
const error = new Error(message || `Timeout: the event ${eventName} was not triggered.`);
// Set up the timeout to reject if the event is not triggered.
let timeoutNoEvent;
const timeoutProm = new Promise((resolve, reject) => {
timeoutNoEvent = setTimeout(() => {
console.warn(error);
reject(error);
}, timeoutDelay);
});
// Set up the promise to resolve if the event is triggered.
const eventProm = makeDeferred();
const eventHandler = ev => {
if (!predicate || predicate(ev.detail)) {
eventProm.resolve();
}
};
messagingBus.addEventListener(eventName, eventHandler);
// Start the function expected to trigger the event after the
// promise has been registered to not miss any potential event.
const funcRes = func();
// Make them race (first to resolve/reject wins).
await Promise.race([eventProm, timeoutProm]).finally(() => {
// Execute clean up regardless of whether the promise is
// rejected or not.
clearTimeout(timeoutNoEvent);
messagingBus.removeEventListener(eventName, eventHandler);
});
// If the event is triggered before the end of the async function,
// ensure the function finishes its job before returning.
return await funcRes;
};
}
function getClick({ afterNextRender }) {
return async function click(selector) {
await afterNextRender(() => {
if (typeof selector === "string") {
$(selector)[0].click();
} else if (selector instanceof HTMLElement) {
selector.click();
} else {
// jquery
selector[0].click();
}
});
};
}
function getMouseenter({ afterNextRender }) {
return async function mouseenter(selector) {
await afterNextRender(() =>
document.querySelector(selector).dispatchEvent(new window.MouseEvent('mouseenter'))
);
};
}
function getOpenDiscuss(afterEvent, webClient, { context = {}, params, ...props } = {}) {
return async function openDiscuss({ waitUntilMessagesLoaded = true } = {}) {
const actionOpenDiscuss = {
// hardcoded actionId, required for discuss_container props validation.
id: 104,
context,
params,
tag: 'mail.action_discuss',
type: 'ir.actions.client',
};
if (waitUntilMessagesLoaded) {
let threadId = context.active_id;
if (typeof threadId === 'string') {
threadId = parseInt(threadId.split('_')[1]);
}
return afterNextRender(() => afterEvent({
eventName: 'o-thread-view-hint-processed',
func: () => doAction(webClient, actionOpenDiscuss, { props }),
message: "should wait until discuss loaded its messages",
predicate: ({ hint, threadViewer }) => {
return (
hint.type === 'messages-loaded' &&
(!threadId || threadViewer.thread.id === threadId)
);
},
}));
}
return afterNextRender(() => doAction(webClient, actionOpenDiscuss, { props }));
};
}
function getOpenFormView(afterEvent, openView) {
return async function openFormView(action, { props, waitUntilDataLoaded = true, waitUntilMessagesLoaded = true } = {}) {
action['views'] = [[false, 'form']];
const func = () => openView(action, props);
const waitData = func => afterNextRender(() => afterEvent({
eventName: 'o-thread-loaded-data',
func,
message: "should wait until chatter loaded its data",
predicate: ({ thread }) => {
return (
thread.model === action.res_model &&
thread.id === action.res_id
);
},
}));
const waitMessages = func => afterNextRender(() => afterEvent({
eventName: 'o-thread-loaded-messages',
func,
message: "should wait until chatter loaded its messages",
predicate: ({ thread }) => {
return (
thread.model === action.res_model &&
thread.id === action.res_id
);
},
}));
if (waitUntilDataLoaded && waitUntilMessagesLoaded) {
return waitData(() => waitMessages(func));
}
if (waitUntilDataLoaded) {
return waitData(func);
}
if (waitUntilMessagesLoaded) {
return waitMessages(func);
}
return func();
};
}
//------------------------------------------------------------------------------
// Public: start function helpers
//------------------------------------------------------------------------------
/**
* Main function used to make a mocked environment with mocked messaging env.
*
* @param {Object} [param0={}]
* @param {Object} [param0.serverData] The data to pass to the webClient
* @param {Object} [param0.discuss={}] provide data that is passed to the discuss action.
* @param {Object} [param0.legacyServices]
* @param {Object} [param0.services]
* @param {function} [param0.mockRPC]
* @param {boolean} [param0.hasTimeControl=false] if set, all flow of time
* with `messaging.browser.setTimeout` are fully controlled by test itself.
* @param {integer} [param0.loadingBaseDelayDuration=0]
* @param {Deferred|Promise} [param0.messagingBeforeCreationDeferred=Promise.resolve()]
* Deferred that let tests block messaging creation and simulate resolution.
* Useful for testing working components when messaging is not yet created.
* @param {string} [param0.waitUntilMessagingCondition='initialized'] Determines
* the condition of messaging when this function is resolved.
* Supported values: ['none', 'created', 'initialized'].
* - 'none': the function resolves regardless of whether messaging is created.
* - 'created': the function resolves when messaging is created, but
* regardless of whether messaging is initialized.
* - 'initialized' (default): the function resolves when messaging is
* initialized.
* To guarantee messaging is not created, test should pass a pending deferred
* as param of `messagingBeforeCreationDeferred`. To make sure messaging is
* not initialized, test should mock RPC `mail/init_messaging` and block its
* resolution.
* @throws {Error} in case some provided parameters are wrong, such as
* `waitUntilMessagingCondition`.
* @returns {Object}
*/
async function start(param0 = {}) {
// patch _.debounce and _.throttle to be fast and synchronous.
patchWithCleanup(_, {
debounce: func => func,
throttle: func => func,
});
const {
discuss = {},
hasTimeControl,
waitUntilMessagingCondition = 'initialized',
} = param0;
const advanceTime = hasTimeControl ? getAdvanceTime() : undefined;
const target = param0['target'] || getFixture();
param0['target'] = target;
if (!['none', 'created', 'initialized'].includes(waitUntilMessagingCondition)) {
throw Error(`Unknown parameter value ${waitUntilMessagingCondition} for 'waitUntilMessaging'.`);
}
const messagingBus = new EventBus();
const afterEvent = getAfterEvent({ messagingBus });
const pyEnv = await getPyEnv();
param0.serverData = param0.serverData || getActionManagerServerData();
param0.serverData.models = { ...pyEnv.getData(), ...param0.serverData.models };
param0.serverData.views = { ...pyEnv.getViews(), ...param0.serverData.views };
let webClient;
await afterNextRender(async () => {
webClient = await getWebClientReady({ ...param0, messagingBus });
if (waitUntilMessagingCondition === 'created') {
await webClient.env.services.messaging.modelManager.messagingCreatedPromise;
}
if (waitUntilMessagingCondition === 'initialized') {
await webClient.env.services.messaging.modelManager.messagingCreatedPromise;
await webClient.env.services.messaging.modelManager.messagingInitializedPromise;
}
});
registerCleanup(async () => {
await webClient.env.services.messaging.modelManager.messagingInitializedPromise;
webClient.env.services.messaging.modelManager.destroy();
delete webClient.env.services.messaging;
delete owl.Component.env.services.messaging;
delete owl.Component.env[wowlServicesSymbol].messaging;
delete owl.Component.env;
});
const openView = async (action, options) => {
action['type'] = action['type'] || 'ir.actions.act_window';
await afterNextRender(() => doAction(webClient, action, { props: options }));
};
return {
advanceTime,
afterEvent,
afterNextRender,
click: getClick({ afterNextRender }),
env: webClient.env,
insertText,
messaging: webClient.env.services.messaging.modelManager.messaging,
mouseenter: getMouseenter({ afterNextRender }),
openDiscuss: getOpenDiscuss(afterEvent, webClient, discuss),
openView,
openFormView: getOpenFormView(afterEvent, openView),
pyEnv,
webClient,
};
}
//------------------------------------------------------------------------------
// Public: file utilities
//------------------------------------------------------------------------------
/**
* Drag some files over a DOM element
*
* @param {DOM.Element} el
* @param {Object[]} file must have been create beforehand
* @see testUtils.file.createFile
*/
function dragenterFiles(el, files) {
const ev = new Event('dragenter', { bubbles: true });
Object.defineProperty(ev, 'dataTransfer', {
value: _createFakeDataTransfer(files),
});
el.dispatchEvent(ev);
}
/**
* Drop some files on a DOM element
*
* @param {DOM.Element} el
* @param {Object[]} files must have been created beforehand
* @see testUtils.file.createFile
*/
function dropFiles(el, files) {
const ev = new Event('drop', { bubbles: true });
Object.defineProperty(ev, 'dataTransfer', {
value: _createFakeDataTransfer(files),
});
el.dispatchEvent(ev);
}
/**
* Paste some files on a DOM element
*
* @param {DOM.Element} el
* @param {Object[]} files must have been created beforehand
* @see testUtils.file.createFile
*/
function pasteFiles(el, files) {
const ev = new Event('paste', { bubbles: true });
Object.defineProperty(ev, 'clipboardData', {
value: _createFakeDataTransfer(files),
});
el.dispatchEvent(ev);
}
//------------------------------------------------------------------------------
// Public: input utilities
//------------------------------------------------------------------------------
/**
* @param {string} selector
* @param {string} content
*/
async function insertText(selector, content) {
await afterNextRender(() => {
document.querySelector(selector).focus();
for (const char of content) {
document.execCommand('insertText', false, char);
document.querySelector(selector).dispatchEvent(new window.KeyboardEvent('keydown', { key: char }));
document.querySelector(selector).dispatchEvent(new window.KeyboardEvent('keyup', { key: char }));
}
});
}
//------------------------------------------------------------------------------
// Public: DOM utilities
//------------------------------------------------------------------------------
/**
* Determine if a DOM element has been totally scrolled
*
* A 1px margin of error is given to accomodate subpixel rounding issues and
* Element.scrollHeight value being either int or decimal
*
* @param {DOM.Element} el
* @returns {boolean}
*/
function isScrolledToBottom(el) {
return Math.abs(el.scrollHeight - el.clientHeight - el.scrollTop) <= 1;
}
//------------------------------------------------------------------------------
// Export
//------------------------------------------------------------------------------
export {
afterNextRender,
dragenterFiles,
dropFiles,
insertText,
isScrolledToBottom,
nextAnimationFrame,
nextTick,
pasteFiles,
start,
startServer,
};

View file

@ -1,54 +0,0 @@
/** @odoo-module **/
import { nextTick } from '@mail/utils/utils';
import { browser } from '@web/core/browser/browser';
import { patchWithCleanup } from "@web/../tests/helpers/utils";
export function getAdvanceTime() {
// list of timeout ids that have timed out.
let timedOutIds = [];
// key: timeoutId, value: func + remaining duration
const timeouts = new Map();
patchWithCleanup(browser, {
clearTimeout: id => {
timeouts.delete(id);
timedOutIds = timedOutIds.filter(i => i !== id);
},
setTimeout: (func, duration) => {
const timeoutId = _.uniqueId('timeout_');
const timeout = {
id: timeoutId,
isTimedOut: false,
func,
duration,
};
timeouts.set(timeoutId, timeout);
if (duration === 0) {
timedOutIds.push(timeoutId);
timeout.isTimedOut = true;
}
return timeoutId;
},
});
return async function (duration) {
await nextTick();
for (const id of timeouts.keys()) {
const timeout = timeouts.get(id);
if (timeout.isTimedOut) {
continue;
}
timeout.duration = Math.max(timeout.duration - duration, 0);
if (timeout.duration === 0) {
timedOutIds.push(id);
}
}
while (timedOutIds.length > 0) {
const id = timedOutIds.shift();
const timeout = timeouts.get(id);
timeouts.delete(id);
timeout.func();
await nextTick();
}
};
}

View file

@ -1,144 +0,0 @@
/** @odoo-module **/
import { busService } from '@bus/services/bus_service';
import { imStatusService } from '@bus/im_status_service';
import { multiTabService } from '@bus/multi_tab_service';
import { makeMultiTabToLegacyEnv } from '@bus/services/legacy/make_multi_tab_to_legacy_env';
import { makeBusServiceToLegacyEnv } from '@bus/services/legacy/make_bus_service_to_legacy_env';
import { makeFakePresenceService } from '@bus/../tests/helpers/mock_services';
import { ChatWindowManagerContainer } from '@mail/components/chat_window_manager_container/chat_window_manager_container';
import { DialogManagerContainer } from '@mail/components/dialog_manager_container/dialog_manager_container';
import { DiscussContainer } from '@mail/components/discuss_container/discuss_container';
import { PopoverManagerContainer } from '@mail/components/popover_manager_container/popover_manager_container';
import { messagingService } from '@mail/services/messaging_service';
import { systrayService } from '@mail/services/systray_service';
import { makeMessagingToLegacyEnv } from '@mail/utils/make_messaging_to_legacy_env';
import { registry } from '@web/core/registry';
import { patchWithCleanup } from "@web/../tests/helpers/utils";
import { createWebClient } from "@web/../tests/webclient/helpers";
const ROUTES_TO_IGNORE = [
'/web/webclient/load_menus',
'/web/dataset/call_kw/res.users/load_views',
'/web/dataset/call_kw/res.users/systray_get_activities'
];
const WEBCLIENT_PARAMETER_NAMES = new Set(['legacyParams', 'mockRPC', 'serverData', 'target', 'webClientClass']);
const SERVICES_PARAMETER_NAMES = new Set([
'legacyServices', 'loadingBaseDelayDuration', 'messagingBeforeCreationDeferred',
'messagingBus', 'services',
]);
/**
* Add required components to the main component registry.
*/
function setupMainComponentRegistry() {
const mainComponentRegistry = registry.category('main_components');
mainComponentRegistry.add('ChatWindowManagerContainer', { Component: ChatWindowManagerContainer });
mainComponentRegistry.add('DialogManagerContainer', { Component: DialogManagerContainer });
registry.category('actions').add('mail.action_discuss', DiscussContainer);
mainComponentRegistry.add('PopoverManagerContainer', { Component: PopoverManagerContainer });
}
/**
* Setup both legacy and new service registries.
*
* @param {Object} param0
* @param {Object} [param0.services]
* @param {number} [param0.loadingBaseDelayDuration=0]
* @param {Promise} [param0.messagingBeforeCreationDeferred=Promise.resolve()]
* Deferred that let tests block messaging creation and simulate resolution.
* Useful for testing components behavior when messaging is not yet created.
* @param {EventBus} [param0.messagingBus]
* @returns {LegacyRegistry} The registry containing all the legacy services that will be passed
* to the webClient as a legacy parameter.
*/
function setupMessagingServiceRegistries({
loadingBaseDelayDuration = 0,
messagingBeforeCreationDeferred = Promise.resolve(),
messagingBus,
services,
}) {
const serviceRegistry = registry.category('services');
patchWithCleanup(messagingService, {
async _startModelManager(modelManager, messagingValues) {
modelManager.isDebug = true;
const _super = this._super.bind(this);
await messagingBeforeCreationDeferred;
return _super(modelManager, messagingValues);
},
});
const messagingValues = {
start() {
return {
isInQUnitTest: true,
disableAnimation: true,
loadingBaseDelayDuration,
messagingBus,
userNotificationManager: { canPlayAudio: false },
};
}
};
services = {
bus_service: busService,
im_status: imStatusService,
messaging: messagingService,
messagingValues,
presence: makeFakePresenceService({
isOdooFocused: () => true,
}),
systrayService,
multi_tab: multiTabService,
...services,
};
Object.entries(services).forEach(([serviceName, service]) => {
serviceRegistry.add(serviceName, service);
});
registry.category('wowlToLegacyServiceMappers').add('bus_service_to_legacy_env', makeBusServiceToLegacyEnv);
registry.category('wowlToLegacyServiceMappers').add('multi_tab_to_legacy_env', makeMultiTabToLegacyEnv);
registry.category('wowlToLegacyServiceMappers').add('messaging_service_to_legacy_env', makeMessagingToLegacyEnv);
}
/**
* Creates a properly configured instance of WebClient, with the messaging service and all it's
* dependencies initialized.
*
* @param {Object} param0
* @param {Object} [param0.serverData]
* @param {Object} [param0.services]
* @param {Object} [param0.loadingBaseDelayDuration]
* @param {Object} [param0.messagingBeforeCreationDeferred]
* @param {EventBus} [param0.messagingBus] The event bus to be used by messaging.
* @returns {WebClient}
*/
async function getWebClientReady(param0) {
setupMainComponentRegistry();
const servicesParameters = {};
const param0Entries = Object.entries(param0);
for (const [parameterName, value] of param0Entries) {
if (SERVICES_PARAMETER_NAMES.has(parameterName)) {
servicesParameters[parameterName] = value;
}
}
setupMessagingServiceRegistries(servicesParameters);
const webClientParameters = {};
for (const [parameterName, value] of param0Entries) {
if (WEBCLIENT_PARAMETER_NAMES.has(parameterName)) {
webClientParameters[parameterName] = value;
}
}
return createWebClient(webClientParameters);
}
export {
getWebClientReady,
ROUTES_TO_IGNORE,
};

View file

@ -0,0 +1,15 @@
import { InputPlugin } from "@html_editor/core/input_plugin";
import { MentionPlugin } from "@mail/views/web/fields/html_composer_message_field/mention_plugin";
import { describe, expect, test } from "@odoo/hoot";
describe("Implicit plugin dependencies", () => {
test("position as an implicit dependency", async () => {
for (const P of [MentionPlugin]) {
// input dependency through the "beforeinput_handlers" and
// "input_handlers" resources. This dependency was added because the
// plugin is heavily dependent on inputs handling and will appear
// broken without the appropriate handlers.
expect(P.dependencies).toInclude(InputPlugin.id);
}
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,157 @@
import { setSelection } from "@html_editor/../tests/_helpers/selection";
import { insertText } from "@html_editor/../tests/_helpers/user_actions";
import { expectElementCount } from "@html_editor/../tests/_helpers/ui_expectations";
import { HtmlMailField } from "@mail/views/web/fields/html_mail_field/html_mail_field";
import { after, before, beforeEach, expect, test } from "@odoo/hoot";
import { press, queryOne } from "@odoo/hoot-dom";
import { animationFrame, enableTransitions } from "@odoo/hoot-mock";
import {
clickSave,
contains,
defineModels,
fields,
models,
mountView,
onRpc,
patchWithCleanup,
} from "@web/../tests/web_test_helpers";
import { mailModels } from "../mail_test_helpers";
function setSelectionInHtmlField(selector = "p", fieldName = "body") {
const anchorNode = queryOne(`[name='${fieldName}'] .odoo-editor-editable ${selector}`);
setSelection({ anchorNode, anchorOffset: 0 });
return anchorNode;
}
function useCustomStyleRules(rules = "") {
let style;
before(() => {
style = document.createElement("STYLE");
style.type = "text/css";
style.append(document.createTextNode(rules));
document.head.append(style);
});
after(() => {
style.remove();
});
}
class CustomMessage extends models.Model {
_name = "custom.message";
title = fields.Char();
body = fields.Html();
_records = [
{ id: 1, title: "first", body: "<p>first</p>" },
{ id: 2, title: "second", body: "<p>second</p>" },
];
_onChanges = {
title(record) {
record.body = `<p>${record.title}</p>`;
},
};
}
defineModels({ ...mailModels, CustomMessage });
let htmlEditor;
beforeEach(() => {
patchWithCleanup(HtmlMailField.prototype, {
onEditorLoad(editor) {
htmlEditor = editor;
return super.onEditorLoad(...arguments);
},
getConfig() {
const config = super.getConfig();
config.Plugins = config.Plugins.filter((Plugin) => Plugin.id !== "editorVersion");
return config;
},
});
});
test("HtmlMail save inline html", async function () {
enableTransitions();
useCustomStyleRules(`.test-h1-inline .note-editable h1 { color: #111827 !important; }`);
onRpc("web_save", ({ args }) => {
expect(args[1].body.replace(/font-size: ?(\d+(\.\d+)?)px/, "font-size: []px")).toBe(
`<h1 style="border-radius:0px;border-style:none;padding:0px;margin:0px 0 8px 0;box-sizing:border-box;border-left-color:#111827;border-bottom-color:#111827;border-right-color:#111827;border-top-color:#111827;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;font-size: []px;color:#111827;line-height:1.2;font-weight:500;font-family:'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Ubuntu, 'Noto Sans', Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';">first</h1>`
);
expect.step("web_save");
});
await mountView({
type: "form",
resId: 1,
resModel: "custom.message",
arch: `
<form>
<field name="body" widget="html_mail" class="test-h1-inline"/>
</form>`,
});
setSelectionInHtmlField();
await insertText(htmlEditor, "/heading1");
await press("enter");
expect(".odoo-editor-editable").toHaveInnerHTML("<h1> first </h1>");
await clickSave();
await expect.waitForSteps(["web_save"]);
});
test("HtmlMail don't have access to column commands", async function () {
await mountView({
type: "form",
resId: 1,
resModel: "custom.message",
arch: `
<form>
<field name="body" widget="html_mail"/>
</form>`,
});
setSelectionInHtmlField();
await insertText(htmlEditor, "/");
await animationFrame();
await expectElementCount(".o-we-powerbox", 1);
await insertText(htmlEditor, "column");
await animationFrame();
await expectElementCount(".o-we-powerbox", 0);
});
test("HtmlMail add icon and save inline html", async function () {
enableTransitions();
useCustomStyleRules(
`.test-icon-inline .note-editable .fa {
color: rgb(55,65,81) !important;
background-color: rgb(249,250,251) !important;
}
p, img {
border-color: #ff0000 !important;
}
`
);
onRpc("web_save", ({ args }) => {
expect(args[1].body).toBe(
`<p style="border-radius:0px;border-style:none;padding:0px;margin:0px 0 16px 0;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;border-left-color:#ff0000;border-bottom-color:#ff0000;border-right-color:#ff0000;border-top-color:#ff0000;"><span style="display: inline-block; width: 14px; height: 14px; vertical-align: text-bottom;" class="oe_unbreakable "><img width="14" height="14" src="/mail/font_to_img/61440/rgb(55%2C65%2C81)/rgb(249%2C250%2C251)/14x14" data-class="fa fa-glass" data-style="null" style="border-radius:0px;border-style:none;padding:0px;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;border-left-color:#ff0000;border-bottom-color:#ff0000;border-right-color:#ff0000;border-top-color:#ff0000;box-sizing: border-box; line-height: 14px; width: 14px; height: 14px; vertical-align: unset; margin: 0px;"></span>first</p>`
);
expect.step("web_save");
});
await mountView({
type: "form",
resId: 1,
resModel: "custom.message",
arch: `
<form>
<field name="body" widget="html_mail" class="test-icon-inline"/>
</form>`,
});
setSelectionInHtmlField();
await insertText(htmlEditor, "/image");
await press("enter");
await contains("a.nav-link:contains('Icons')").click();
await contains("span.fa-glass").click();
await clickSave();
await expect.waitForSteps(["web_save"]);
});

View file

@ -0,0 +1,149 @@
import {
TABLE_ATTRIBUTES,
TABLE_STYLES,
} from "@mail/views/web/fields/html_mail_field/convert_inline";
const tableAttributesString = Object.keys(TABLE_ATTRIBUTES)
.map((key) => `${key}="${TABLE_ATTRIBUTES[key]}"`)
.join(" ");
const tableStylesString = Object.keys(TABLE_STYLES)
.map((key) => `${key}: ${TABLE_STYLES[key]};`)
.join(" ");
/**
* Take a matrix representing a grid and return an HTML string of the Bootstrap
* grid. The matrix is an array of rows, with each row being an array of cells.
* Each cell can be represented either by a 0 < number < 13 (col-#) or a falsy
* value (col). Each cell has its coordinates `(row index, column index)` as
* text content.
* Eg: [ // <div class="container">
* [ // <div class="row">
* 1, // <div class="col-1">(0, 0)</div>
* 11, // <div class="col-11">(0, 1)</div>
* ], // </div>
* [ // <div class="row">
* false, // <div class="col">(1, 0)</div>
* ], // </div>
* ] // </div>
*
* @param {Array<Array<Number|null>>} matrix
* @returns {string}
*/
export function getGridHtml(matrix) {
return (
`<div class="container">` +
matrix
.map(
(row, iRow) =>
`<div class="row">` +
row
.map(
(col, iCol) =>
`<div class="${
col ? "col-" + col : "col"
}">(${iRow}, ${iCol})</div>`
)
.join("") +
`</div>`
)
.join("") +
`</div>`
);
}
export function getTdHtml(colspan, text, containerWidth) {
const style = containerWidth
? ` style="max-width: ${Math.round(((containerWidth * colspan) / 12) * 100) / 100}px;"`
: "";
return `<td colspan="${colspan}"${style}>${text}</td>`;
}
/**
* Take a matrix representing a table and return an HTML string of the table.
* The matrix is an array of rows, with each row being an array of cells. Each
* cell is represented by a tuple of numbers [colspan, width (in percent)]. A
* cell can have a string as third value to represent its text content. The
* default text content of each cell is its coordinates `(row index, column
* index)`. If the cell has a number as third value, it will be used as the
* max-width of the cell (in pixels).
* Eg: [ // <table> (note: extra attrs and styles apply)
* [ // <tr>
* [1, 8], // <td colspan="1" width="8%">(0, 0)</td>
* [11, 92] // <td colspan="11" width="92%">(0, 1)</td>
* ], // </tr>
* [ // <tr>
* [2, 17, 'A'], // <td colspan="2" width="17%">A</td>
* [10, 83], // <td colspan="10" width="83%">(1, 1)</td>
* ], // </tr>
* ] // </table>
*
* @param {Array<Array<Array<[Number, Number, string?, number?]>>>} matrix
* @param {Number} [containerWidth]
* @returns {string}
*/
export function getTableHtml(matrix, containerWidth) {
return (
`<table ${tableAttributesString} style="width: 100% !important; ${tableStylesString}">` +
matrix
.map(
(row, iRow) =>
`<tr>` +
row
.map((col, iCol) =>
getTdHtml(
col[0],
typeof col[2] === "string" ? col[2] : `(${iRow}, ${iCol})`,
containerWidth
)
)
.join("") +
`</tr>`
)
.join("") +
`</table>`
);
}
/**
* Take a number of rows and a number of columns (or number of columns per
* individual row) and return an HTML string of the corresponding grid. Every
* column is a regular Bootstrap "col" (no col-#).
* Eg: [2, 3] <=> getGridHtml([[false, false, false], [false, false, false]])
* Eg: [2, [2, 1]] <=> getGridHtml([[false, false], [false]])
*
* @see getGridHtml
* @param {Number} nRows
* @param {Number|Number[]} nCols
* @returns {string}
*/
export function getRegularGridHtml(nRows, nCols) {
const matrix = new Array(nRows)
.fill()
.map((_, iRow) => new Array(Array.isArray(nCols) ? nCols[iRow] : nCols).fill());
return getGridHtml(matrix);
}
/**
* Take a number of rows, a number of columns (or number of columns per
* individual row), a colspan (or colspan per individual row) and a width (or
* width per individual row, in percent), and return an HTML string of the
* corresponding table. Every cell in a row has the same colspan/width.
* Eg: [2, 2, 6, 50] <=> getTableHtml([[[6, 50], [6, 50]], [[6, 50], [6, 50]]])
* Eg: [2, [2, 1], [6, 12], [50, 100]] <=> getTableHtml([[[6, 50], [6, 50]], [[12, 100]]])
*
* @see getTableHtml
* @param {Number} nRows
* @param {Number|Number[]} nCols
* @param {Number|Number[]} colspan
* @param {Number|Number[]} width
* @param {Number} containerWidth
* @returns {string}
*/
export function getRegularTableHtml(nRows, nCols, colspan, width, containerWidth) {
const matrix = new Array(nRows)
.fill()
.map((_, iRow) =>
new Array(Array.isArray(nCols) ? nCols[iRow] : nCols)
.fill()
.map(() => [
Array.isArray(colspan) ? colspan[iRow] : colspan,
Array.isArray(width) ? width[iRow] : width,
])
);
return getTableHtml(matrix, containerWidth);
}

View file

@ -0,0 +1,39 @@
import * as viewHelpers from "@web/../tests/views/helpers";
import * as webClientHelpers from "@web/../tests/webclient/helpers";
import { registry } from "@web/core/registry";
const serviceRegistry = registry.category("services");
function registerFakemailPopoutService() {
if (!serviceRegistry.contains("mail.popout")) {
serviceRegistry.add("mail.popout", {
start() {
return {
get externalWindow() {
return null;
},
popout() {},
reset() {},
};
},
});
}
}
const superSetupViewRegistries = viewHelpers.setupViewRegistries
viewHelpers.setupViewRegistries = () => {
registerFakemailPopoutService()
return superSetupViewRegistries();
}
const superSetupWebClientRegistries = webClientHelpers.setupWebClientRegistries
webClientHelpers.setupWebClientRegistries = () => {
registerFakemailPopoutService()
return superSetupWebClientRegistries();
}
const superCreateWebClient = webClientHelpers.createWebClient
webClientHelpers.createWebClient = (params) => {
registerFakemailPopoutService()
return superCreateWebClient(params);
}

View file

@ -0,0 +1,71 @@
import { click, contains, openDiscuss, start, startServer } from "@mail/../tests/mail_test_helpers";
import { expect, mockTouch, mockUserAgent, queryFirst } from "@odoo/hoot";
export async function mailCanAddMessageReactionMobile() {
mockTouch(true);
mockUserAgent("android");
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
pyEnv["mail.message"].create([
{
body: "Hello world",
res_id: channelId,
message_type: "comment",
model: "discuss.channel",
},
{
body: "Hello Odoo",
res_id: channelId,
message_type: "comment",
model: "discuss.channel",
},
]);
await start();
await openDiscuss(channelId);
await contains(".o-mail-Message", { count: 2 });
await contains(".o-mail-Message:contains('Hello world')");
await click(".o-mail-Message:contains('Hello world') [title='Expand']");
await click(".o-dropdown-item:contains('Add a Reaction')");
await contains(".o-overlay-item:has(.modal .o-EmojiPicker)");
const emojiPickerZIndex = parseInt(
getComputedStyle(queryFirst(".o-overlay-item:has(.modal .o-EmojiPicker)")).zIndex
);
const chatWindowZIndex = parseInt(getComputedStyle(queryFirst(".o-mail-ChatWindow")).zIndex);
expect(chatWindowZIndex).toBeLessThan(emojiPickerZIndex, {
message: "emoji picker modal should be above chat window",
});
await click(".modal .o-EmojiPicker .o-Emoji:contains('😀')");
await contains(".o-mail-MessageReaction:contains('😀')");
// Can quickly add new reactions
await click(".o-mail-MessageReactions button[title='Add a Reaction']");
await click(".modal .o-EmojiPicker .o-Emoji:contains('🤣')");
await contains(".o-mail-MessageReaction:contains('🤣')");
await contains(".o-mail-MessageReaction:contains('😀')");
}
export async function mailCanCopyTextToClipboardMobile() {
mockTouch(true);
mockUserAgent("android");
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
pyEnv["mail.message"].create([
{
body: "Hello world",
res_id: channelId,
message_type: "comment",
model: "discuss.channel",
},
{
body: "Hello Odoo",
res_id: channelId,
message_type: "comment",
model: "discuss.channel",
},
]);
await start();
await openDiscuss(channelId);
await contains(".o-mail-Message", { count: 2 });
await contains(".o-mail-Message:contains('Hello world')");
await click(".o-mail-Message:contains('Hello world') [title='Expand']");
await contains(".o-dropdown-item:contains('Copy to Clipboard')");
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,14 @@
.o-mail-Discuss-asTabContainer {
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
position: absolute;
top: 0;
left: 0;
& .o_action_manager {
height: 100%;
}
}

View file

@ -0,0 +1,984 @@
/** @odoo-module alias=@web/../tests/utils default=false */
import { __debug__, after, afterEach, expect, getFixture } from "@odoo/hoot";
import { queryAll, queryFirst } from "@odoo/hoot-dom";
import { Deferred, animationFrame, tick } from "@odoo/hoot-mock";
import { isMacOS } from "@web/core/browser/feature_detection";
import { isVisible } from "@web/core/utils/ui";
/**
* Use `expect.step` instead
* @deprecated
*/
export const step = expect.step;
/**
* Use `expect.waitForSteps` instead
* @deprecated
*/
export const assertSteps = expect.waitForSteps;
/** @param {EventInit} [args] */
const mapBubblingEvent = (args) => ({ ...args, bubbles: true });
/** @param {EventInit} [args] */
const mapNonBubblingEvent = (args) => ({ ...args, bubbles: false });
/** @param {EventInit} [args={}] */
const mapBubblingPointerEvent = (args = {}) => ({
clientX: args.pageX,
clientY: args.pageY,
...args,
bubbles: true,
cancelable: true,
view: window,
});
/** @param {EventInit} [args] */
const mapNonBubblingPointerEvent = (args) => ({
...mapBubblingPointerEvent(args),
bubbles: false,
cancelable: false,
});
/** @param {EventInit} [args={}] */
const mapCancelableTouchEvent = (args = {}) => ({
...args,
bubbles: true,
cancelable: true,
composed: true,
rotation: 0.0,
touches: args.touches ? [...args.touches.map((e) => new Touch(e))] : undefined,
view: window,
zoom: 1.0,
});
/** @param {EventInit} [args] */
const mapNonCancelableTouchEvent = (args) => ({
...mapCancelableTouchEvent(args),
cancelable: false,
});
/** @param {EventInit} [args] */
const mapKeyboardEvent = (args) => ({
...args,
bubbles: true,
cancelable: true,
});
/**
* @template {typeof Event} T
* @param {EventType} eventType
* @returns {[T, (attrs: EventInit) => EventInit]}
*/
const getEventConstructor = (eventType) => {
switch (eventType) {
// Mouse events
case "auxclick":
case "click":
case "contextmenu":
case "dblclick":
case "mousedown":
case "mouseup":
case "mousemove":
case "mouseover":
case "mouseout": {
return [MouseEvent, mapBubblingPointerEvent];
}
case "mouseenter":
case "mouseleave": {
return [MouseEvent, mapNonBubblingPointerEvent];
}
// Pointer events
case "pointerdown":
case "pointerup":
case "pointermove":
case "pointerover":
case "pointerout": {
return [PointerEvent, mapBubblingPointerEvent];
}
case "pointerenter":
case "pointerleave": {
return [PointerEvent, mapNonBubblingPointerEvent];
}
// Focus events
case "focusin": {
return [FocusEvent, mapBubblingEvent];
}
case "focus":
case "blur": {
return [FocusEvent, mapNonBubblingEvent];
}
// Clipboard events
case "cut":
case "copy":
case "paste": {
return [ClipboardEvent, mapBubblingEvent];
}
// Keyboard events
case "keydown":
case "keypress":
case "keyup": {
return [KeyboardEvent, mapKeyboardEvent];
}
// Drag events
case "drag":
case "dragend":
case "dragenter":
case "dragstart":
case "dragleave":
case "dragover":
case "drop": {
return [DragEvent, mapBubblingEvent];
}
// Input events
case "input": {
return [InputEvent, mapBubblingEvent];
}
// Composition events
case "compositionstart":
case "compositionend": {
return [CompositionEvent, mapBubblingEvent];
}
// UI events
case "scroll": {
return [UIEvent, mapNonBubblingEvent];
}
// Touch events
case "touchstart":
case "touchend":
case "touchmove": {
return [TouchEvent, mapCancelableTouchEvent];
}
case "touchcancel": {
return [TouchEvent, mapNonCancelableTouchEvent];
}
// Default: base Event constructor
default: {
return [Event, mapBubblingEvent];
}
}
};
function findElement(el, selector) {
let target = el;
if (selector) {
const els = el.querySelectorAll(selector);
if (els.length === 0) {
throw new Error(`No element found (selector: ${selector})`);
}
if (els.length > 1) {
throw new Error(`Found ${els.length} elements, instead of 1 (selector: ${selector})`);
}
target = els[0];
}
return target;
}
/**
* @template {EventType} T
* @param {Element} el
* @param {string | null | undefined | false} selector
* @param {T} eventType
* @param {EventInit} [eventInit]
* @param {TriggerEventOptions} [options={}]
* @returns {GlobalEventHandlersEventMap[T] | Promise<GlobalEventHandlersEventMap[T]>}
*/
function triggerEvent(el, selector, eventType, eventInit, options = {}) {
const errors = [];
const target = findElement(el, selector);
// Error handling
if (typeof eventType !== "string") {
errors.push("event type must be a string");
}
if (!target) {
errors.push("cannot find target");
} else if (!options.skipVisibilityCheck && !isVisible(target)) {
errors.push("target is not visible");
}
if (errors.length) {
throw new Error(
`Cannot trigger event${eventType ? ` "${eventType}"` : ""}${
selector ? ` (with selector "${selector}")` : ""
}: ${errors.join(" and ")}`
);
}
// Actual dispatch
const [Constructor, processParams] = getEventConstructor(eventType);
const event = new Constructor(eventType, processParams(eventInit));
target.dispatchEvent(event);
if (__debug__.debug) {
const group = `%c[${event.type.toUpperCase()}]`;
console.groupCollapsed(group, "color: #b52c9b");
console.log(target, event);
console.groupEnd(group, "color: #b52c9b");
}
if (options.sync) {
return event;
} else {
return tick().then(() => event);
}
}
/**
* @param {Element} el
* @param {string | null | undefined | false} selector
* @param {(EventType | [EventType, EventInit])[]} [eventDefs]
* @param {TriggerEventOptions} [options={}]
*/
function _triggerEvents(el, selector, eventDefs, options = {}) {
const events = [...eventDefs].map((eventDef) => {
const [eventType, eventInit] = Array.isArray(eventDef) ? eventDef : [eventDef, {}];
return triggerEvent(el, selector, eventType, eventInit, options);
});
if (options.sync) {
return events;
} else {
return tick().then(() => events);
}
}
function _click(
el,
selector,
{ mouseEventInit = {}, skipDisabledCheck = false, skipVisibilityCheck = false } = {}
) {
if (!skipDisabledCheck && el.disabled) {
throw new Error("Can't click on a disabled button");
}
return _triggerEvents(
el,
selector,
[
"pointerdown",
"mousedown",
"focus",
"focusin",
"pointerup",
"mouseup",
["click", mouseEventInit],
],
{ skipVisibilityCheck }
);
}
export async function editInput(el, selector, value) {
const input = findElement(el, selector);
if (!(input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement)) {
throw new Error("Only 'input' and 'textarea' elements can be edited with 'editInput'.");
}
if (
![
"text",
"textarea",
"email",
"search",
"color",
"number",
"file",
"tel",
"range",
].includes(input.type)
) {
throw new Error(`Type "${input.type}" not supported by 'editInput'.`);
}
const eventOpts = {};
if (input.type === "file") {
const files = Array.isArray(value) ? value : [value];
const dataTransfer = new DataTransfer();
for (const file of files) {
if (!(file instanceof File)) {
throw new Error(`File input value should be one or several File objects.`);
}
dataTransfer.items.add(file);
}
input.files = dataTransfer.files;
eventOpts.skipVisibilityCheck = true;
} else {
input.value = value;
}
await _triggerEvents(input, null, ["input", "change"], eventOpts);
if (input.type === "file") {
// Need to wait for the file to be loaded by the input
await tick();
await tick();
}
}
/**
* Create a fake object 'dataTransfer', linked to some files,
* which is passed to drag and drop events.
*
* @param {Object[]} files
* @returns {Object}
*/
function createFakeDataTransfer(files) {
return {
dropEffect: "all",
effectAllowed: "all",
files,
items: [],
getData: () => "",
types: ["Files"],
};
}
/**
* Waits until exactly one element matching the given `selector` is present in
* `options.target` and then clicks on it.
*
* @param {import("@odoo/hoot-dom").Target} selector
* @param {ContainsOptions} [options] forwarded to `contains`
* @param {boolean} [options.shiftKey]
*/
export async function click(selector, options = {}) {
const { shiftKey } = options;
delete options.shiftKey;
await contains(selector, { click: { shiftKey }, ...options });
}
/**
* Waits until exactly one element matching the given `selector` is present in
* `options.target` and then dragenters `files` on it.
*
* @param {import("@odoo/hoot-dom").Target} selector
* @param {Object[]} files
* @param {ContainsOptions} [options] forwarded to `contains`
*/
export async function dragenterFiles(selector, files, options) {
await contains(selector, { dragenterFiles: files, ...options });
}
/**
* Waits until exactly one element matching the given `selector` is present in
* `options.target` and then dragovers `files` on it.
*
* @param {import("@odoo/hoot-dom").Target} selector
* @param {Object[]} files
* @param {ContainsOptions} [options] forwarded to `contains`
*/
export async function dragoverFiles(selector, files, options) {
await contains(selector, { dragoverFiles: files, ...options });
}
/**
* Waits until exactly one element matching the given `selector` is present in
* `options.target` and then drops `files` on it.
*
* @param {import("@odoo/hoot-dom").Target} selector
* @param {Object[]} files
* @param {ContainsOptions} [options] forwarded to `contains`
*/
export async function dropFiles(selector, files, options) {
await contains(selector, { dropFiles: files, ...options });
}
/**
* Waits until exactly one element matching the given `selector` is present in
* `options.target` and then inputs `files` on it.
*
* @param {import("@odoo/hoot-dom").Target} selector
* @param {Object[]} files
* @param {ContainsOptions} [options] forwarded to `contains`
*/
export async function inputFiles(selector, files, options) {
await contains(selector, { inputFiles: files, ...options });
}
/**
* Waits until exactly one element matching the given `selector` is present in
* `options.target` and then pastes `files` on it.
*
* @param {import("@odoo/hoot-dom").Target} selector
* @param {Object[]} files
* @param {ContainsOptions} [options] forwarded to `contains`
*/
export async function pasteFiles(selector, files, options) {
await contains(selector, { pasteFiles: files, ...options });
}
/**
* Waits until exactly one element matching the given `selector` is present in
* `options.target` and then focuses on it.
*
* @param {import("@odoo/hoot-dom").Target} selector
* @param {ContainsOptions} [options] forwarded to `contains`
*/
export async function focus(selector, options) {
await contains(selector, { setFocus: true, ...options });
}
/**
* Waits until exactly one element matching the given `selector` is present in
* `options.target` and then inserts the given `content`.
*
* @param {import("@odoo/hoot-dom").Target} selector
* @param {string} content
* @param {ContainsOptions} [options] forwarded to `contains`
* @param {boolean} [options.replace=false]
*/
export async function insertText(selector, content, options = {}) {
const { replace = false } = options;
delete options.replace;
await contains(selector, { ...options, insertText: { content, replace } });
await animationFrame(); // wait for t-model synced with new value
}
/**
* Waits until exactly one element matching the given `selector` is present in
* `options.target` and then sets its `scrollTop` to the given value.
*
* @param {import("@odoo/hoot-dom").Target} selector
* @param {number|"bottom"} scrollTop
* @param {ContainsOptions} [options] forwarded to `contains`
*/
export async function scroll(selector, scrollTop, options) {
await contains(selector, { setScroll: scrollTop, ...options });
}
/**
* Waits until exactly one element matching the given `selector` is present in
* `options.target` and then triggers `event` on it.
*
* @param {import("@odoo/hoot-dom").Target} selector
* @param {(import("@web/../tests/helpers/utils").EventType|[import("@web/../tests/helpers/utils").EventType, EventInit])[]} events
* @param {ContainsOptions} [options] forwarded to `contains`
*/
export async function triggerEvents(selector, events, options) {
await contains(selector, { triggerEvents: events, ...options });
}
/**
* Triggers an hotkey properly disregarding the operating system.
*
* @param {string} hotkey
* @param {boolean} addOverlayModParts
* @param {KeyboardEventInit} eventAttrs
*/
export async function triggerHotkey(hotkey, addOverlayModParts = false, eventAttrs = {}) {
eventAttrs.key = hotkey.split("+").pop();
if (/shift/i.test(hotkey)) {
eventAttrs.shiftKey = true;
}
if (/control/i.test(hotkey)) {
if (isMacOS()) {
eventAttrs.metaKey = true;
} else {
eventAttrs.ctrlKey = true;
}
}
if (/alt/i.test(hotkey) || addOverlayModParts) {
if (isMacOS()) {
eventAttrs.ctrlKey = true;
} else {
eventAttrs.altKey = true;
}
}
if (!("bubbles" in eventAttrs)) {
eventAttrs.bubbles = true;
}
const [keydownEvent, keyupEvent] = await _triggerEvents(
document.activeElement,
null,
[
["keydown", eventAttrs],
["keyup", eventAttrs],
],
{ skipVisibilityCheck: true }
);
return { keydownEvent, keyupEvent };
}
function log(ok, message) {
expect(Boolean(ok)).toBe(true, { message });
}
let hasUsedContainsPositively = false;
afterEach(() => (hasUsedContainsPositively = false));
/**
* @typedef {[string, ContainsOptions]} ContainsTuple tuple representing params of the contains
* function, where the first element is the selector, and the second element is the options param.
* @typedef {Object} ContainsOptions
* @property {ContainsTuple} [after] if provided, the found element(s) must be after the element
* matched by this param.
* @property {ContainsTuple} [before] if provided, the found element(s) must be before the element
* matched by this param.
* @property {Object} [click] if provided, clicks on the first found element
* @property {ContainsTuple|ContainsTuple[]} [contains] if provided, the found element(s) must
* contain the provided sub-elements.
* @property {number} [count=1] numbers of elements to be found to declare the contains check
* as successful. Elements are counted after applying all other filters.
* @property {Object[]} [dragenterFiles] if provided, dragenters the given files on the found element
* @property {Object[]} [dragoverFiles] if provided, dragovers the given files on the found element
* @property {Object[]} [dropFiles] if provided, drops the given files on the found element
* @property {Object[]} [inputFiles] if provided, inputs the given files on the found element
* @property {{content:string, replace:boolean}} [insertText] if provided, adds to (or replace) the
* value of the first found element by the given content.
* @property {ContainsTuple} [parent] if provided, the found element(s) must have as
* parent the node matching the parent parameter.
* @property {Object[]} [pasteFiles] if provided, pastes the given files on the found element
* @property {number|"bottom"} [scroll] if provided, the scrollTop of the found element(s)
* must match.
* Note: when using one of the scrollTop options, it is advised to ensure the height is not going
* to change soon, by checking with a preceding contains that all the expected elements are in DOM.
* @property {boolean} [setFocus] if provided, focuses the first found element.
* @property {boolean} [shadowRoot] if provided, targets the shadowRoot of the found elements.
* @property {number|"bottom"} [setScroll] if provided, sets the scrollTop on the first found
* element.
* @property {HTMLElement|OdooEnv} [target=getFixture()]
* @property {string[]} [triggerEvents] if provided, triggers the given events on the found element
* @property {string} [text] if provided, the textContent of the found element(s) or one of their
* descendants must match. Use `textContent` option for a match on the found element(s) only.
* @property {string} [textContent] if provided, the textContent of the found element(s) must match.
* Prefer `text` option for a match on the found element(s) or any of their descendants, usually
* allowing for a simpler and less specific selector.
* @property {string} [value] if provided, the input value of the found element(s) must match.
* Note: value changes are not observed directly, another mutation must happen to catch them.
* @property {boolean} [visible] if provided, the found element(s) must be (in)visible
*/
class Contains {
timeoutCount = 0;
/**
* @param {import("@odoo/hoot-dom").Target} selector
* @param {ContainsOptions} [options={}]
*/
constructor(selector, options = {}) {
this.selector = selector;
this.options = options;
this.options.count ??= 1;
let targetParam;
if (this.options.target?.testEnv) {
// when OdooEnv, special key `target`. See @start
targetParam = this.options.target?.target;
}
if (!targetParam) {
targetParam = this.options.target;
}
this.options.target = targetParam || getFixture();
let selectorMessage = `${this.options.count} of "${this.selector}"`;
if (this.options.visible !== undefined) {
selectorMessage = `${selectorMessage} ${
this.options.visible ? "visible" : "invisible"
}`;
}
if (targetParam) {
selectorMessage = `${selectorMessage} inside a specific target`;
}
if (this.options.parent) {
selectorMessage = `${selectorMessage} inside a specific parent`;
}
if (this.options.contains) {
selectorMessage = `${selectorMessage} with a specified sub-contains`;
}
if (this.options.text !== undefined) {
selectorMessage = `${selectorMessage} with text "${this.options.text}"`;
}
if (this.options.textContent !== undefined) {
selectorMessage = `${selectorMessage} with textContent "${this.options.textContent}"`;
}
if (this.options.value !== undefined) {
selectorMessage = `${selectorMessage} with value "${this.options.value}"`;
}
if (this.options.scroll !== undefined) {
selectorMessage = `${selectorMessage} with scroll "${this.options.scroll}"`;
}
if (this.options.after !== undefined) {
selectorMessage = `${selectorMessage} after a specified element`;
}
if (this.options.before !== undefined) {
selectorMessage = `${selectorMessage} before a specified element`;
}
this.selectorMessage = selectorMessage;
if (this.options.contains && !Array.isArray(this.options.contains[0])) {
this.options.contains = [this.options.contains];
}
if (this.options.count) {
hasUsedContainsPositively = true;
} else if (!hasUsedContainsPositively) {
throw new Error(
`Starting a test with "contains" of count 0 for selector "${this.selector}" is useless because it might immediately resolve. Start the test by checking that an expected element actually exists.`
);
}
/** @type {string} */
this.successMessage = undefined;
/** @type {function} */
this.executeError = undefined;
}
setTickTimeout() {
this.timer = setTimeout(() => {
this.timeoutCount++;
const res = this.runOnce(
`Timeout of ${(this.timeoutCount * this.tickTimeoutDelay) / 1000} seconds`,
{ crashOnFail: this.timeoutCount >= 3000 / this.tickTimeoutDelay }
);
if (!res) {
this.setTickTimeout();
}
}, this.tickTimeoutDelay);
}
/**
* Starts this contains check, either immediately resolving if there is a
* match, or registering appropriate listeners and waiting until there is a
* match or a timeout (resolving or rejecting respectively).
*
* Success or failure messages will be logged with HOOT as well.
*
* @returns {Promise}
*/
run() {
this.done = false;
this.def = new Deferred();
this.scrollListeners = new Set();
this.onBlur = () => this.runOnce("after blur");
this.onChange = () => this.runOnce("after change");
this.onFocus = () => this.runOnce("after focus");
this.onScroll = () => this.runOnce("after scroll");
if (!this.runOnce("immediately")) {
const hasValue =
this.options.value !== undefined ||
(typeof this.selector === "string" && this.selector.includes(":value"));
this.tickTimeoutDelay = hasValue ? 500 : 3000;
this.setTickTimeout();
this.observer = new MutationObserver((mutations) => {
try {
this.runOnce("after mutations");
} catch (e) {
this.def.reject(e); // prevents infinite loop in case of programming error
}
});
this.observer.observe(document.body, {
attributes: true,
childList: true,
subtree: true,
});
document.body.addEventListener("blur", this.onBlur, { capture: true });
document.body.addEventListener("change", this.onChange, { capture: true });
document.body.addEventListener("focus", this.onFocus, { capture: true });
after(() => {
if (!this.done) {
this.runOnce("Test ended", { crashOnFail: true });
}
});
}
return this.def;
}
/**
* Runs this contains check once, immediately returning the result (or
* undefined), and possibly resolving or rejecting the main promise
* (and printing HOOT log) depending on options.
* If undefined is returned it means the check was not successful.
*
* @param {string} whenMessage
* @param {Object} [options={}]
* @param {boolean} [options.crashOnFail=false]
* @param {boolean} [options.executeOnSuccess=true]
* @returns {HTMLElement[]|undefined}
*/
runOnce(whenMessage, { crashOnFail = false, executeOnSuccess = true } = {}) {
const res = this.select();
if ((res?.length ?? 0) === this.options.count || crashOnFail) {
// clean before doing anything else to avoid infinite loop due to side effects
this.observer?.disconnect();
clearTimeout(this.timer);
for (const el of this.scrollListeners ?? []) {
el.removeEventListener("scroll", this.onScroll);
}
document.body.removeEventListener("blur", this.onBlur, { capture: true });
document.body.removeEventListener("change", this.onChange, { capture: true });
document.body.removeEventListener("focus", this.onFocus, { capture: true });
this.done = true;
}
if ((res?.length ?? 0) === this.options.count) {
this.successMessage = `Found ${this.selectorMessage} (${whenMessage})`;
if (executeOnSuccess) {
this.executeAction(res[0]);
}
return res;
} else {
this.executeError = () => {
let message = `Failed to find ${this.selectorMessage} (${whenMessage}).`;
message = res
? `${message} Found ${res.length} instead.`
: `${message} Parent not found.`;
if (this.parentContains) {
if (this.parentContains.successMessage) {
log(true, this.parentContains.successMessage);
} else {
this.parentContains.executeError();
}
}
log(false, message);
this.def?.reject(new Error(message));
for (const childContains of this.childrenContains || []) {
if (childContains.successMessage) {
log(true, childContains.successMessage);
} else {
childContains.executeError();
}
}
};
if (crashOnFail) {
this.executeError();
}
}
}
/**
* Executes the action(s) given to this constructor on the found element,
* prints the success messages, and resolves the main deferred.
* @param {HTMLElement} el
*/
executeAction(el) {
let message = this.successMessage;
if (this.options.click) {
message = `${message} and clicked it`;
_click(el, undefined, {
mouseEventInit: this.options.click,
skipDisabledCheck: true,
skipVisibilityCheck: true,
});
}
if (this.options.dragenterFiles) {
message = `${message} and dragentered ${this.options.dragenterFiles.length} file(s)`;
const ev = new Event("dragenter", { bubbles: true });
Object.defineProperty(ev, "dataTransfer", {
value: createFakeDataTransfer(this.options.dragenterFiles),
});
el.dispatchEvent(ev);
}
if (this.options.dragoverFiles) {
message = `${message} and dragovered ${this.options.dragoverFiles.length} file(s)`;
const ev = new Event("dragover", { bubbles: true });
Object.defineProperty(ev, "dataTransfer", {
value: createFakeDataTransfer(this.options.dragoverFiles),
});
el.dispatchEvent(ev);
}
if (this.options.dropFiles) {
message = `${message} and dropped ${this.options.dropFiles.length} file(s)`;
const ev = new Event("drop", { bubbles: true });
Object.defineProperty(ev, "dataTransfer", {
value: createFakeDataTransfer(this.options.dropFiles),
});
el.dispatchEvent(ev);
}
if (this.options.inputFiles) {
message = `${message} and inputted ${this.options.inputFiles.length} file(s)`;
// could not use _createFakeDataTransfer as el.files assignation will only
// work with a real FileList object.
const dataTransfer = new window.DataTransfer();
for (const file of this.options.inputFiles) {
dataTransfer.items.add(file);
}
el.files = dataTransfer.files;
/**
* Changing files programatically is not supposed to trigger the event but
* it does in Chrome versions before 73 (which is on runbot), so in that
* case there is no need to make a manual dispatch, because it would lead to
* the files being added twice.
*/
const versionRaw = navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./);
const chromeVersion = versionRaw ? parseInt(versionRaw[2], 10) : false;
if (!chromeVersion || chromeVersion >= 73) {
el.dispatchEvent(new Event("change"));
}
}
if (this.options.insertText !== undefined) {
message = `${message} and inserted text "${this.options.insertText.content}" (replace: ${this.options.insertText.replace})`;
el.focus();
if (this.options.insertText.replace) {
el.value = "";
el.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Backspace" }));
el.dispatchEvent(new window.KeyboardEvent("keyup", { key: "Backspace" }));
el.dispatchEvent(new window.InputEvent("input"));
}
for (const char of this.options.insertText.content) {
el.value += char;
el.dispatchEvent(new window.KeyboardEvent("keydown", { key: char }));
el.dispatchEvent(new window.KeyboardEvent("keyup", { key: char }));
el.dispatchEvent(new window.InputEvent("input"));
}
el.dispatchEvent(new window.InputEvent("change"));
}
if (this.options.pasteFiles) {
message = `${message} and pasted ${this.options.pasteFiles.length} file(s)`;
const ev = new Event("paste", { bubbles: true });
Object.defineProperty(ev, "clipboardData", {
value: createFakeDataTransfer(this.options.pasteFiles),
});
el.dispatchEvent(ev);
}
if (this.options.setFocus) {
message = `${message} and focused it`;
el.focus();
}
if (this.options.setScroll !== undefined) {
message = `${message} and set scroll to "${this.options.setScroll}"`;
el.scrollTop =
this.options.setScroll === "bottom" ? el.scrollHeight : this.options.setScroll;
}
if (this.options.triggerEvents) {
message = `${message} and triggered "${this.options.triggerEvents.join(", ")}" events`;
_triggerEvents(el, null, this.options.triggerEvents, {
skipVisibilityCheck: true,
});
}
if (this.parentContains) {
log(true, this.parentContains.successMessage);
}
log(true, message);
for (const childContains of this.childrenContains) {
log(true, childContains.successMessage);
}
this.def?.resolve();
}
/**
* Returns the found element(s) according to this constructor setup.
* If undefined is returned it means the parent cannot be found
*
* @returns {HTMLElement[]|undefined}
*/
select() {
const target = this.selectParent();
if (!target) {
return;
}
let elems;
if (target === getFixture() && queryFirst(this.selector) === target) {
elems = [target];
} else {
elems = queryAll(this.selector, { root: target });
}
const baseRes = elems
.map((el) => (this.options.shadowRoot ? el.shadowRoot : el))
.filter((el) => el);
/** @type {Contains[]} */
this.childrenContains = [];
const res = baseRes.filter((el, currentIndex) => {
let condition =
(this.options.textContent === undefined ||
el.textContent.trim() === this.options.textContent) &&
(this.options.value === undefined || el.value === this.options.value) &&
(this.options.scroll === undefined ||
(this.options.scroll === "bottom"
? Math.abs(el.scrollHeight - el.clientHeight - el.scrollTop) <= 1
: Math.abs(el.scrollTop - this.options.scroll) <= 1));
if (condition && this.options.text !== undefined) {
if (
el.textContent.trim() !== this.options.text &&
[...el.querySelectorAll("*")].every(
(el) => el.textContent.trim() !== this.options.text
)
) {
condition = false;
}
}
if (condition && this.options.contains) {
for (const param of this.options.contains) {
const childContains = new Contains(param[0], { ...param[1], target: el });
if (
!childContains.runOnce(`as child of el ${currentIndex + 1})`, {
executeOnSuccess: false,
})
) {
condition = false;
}
this.childrenContains.push(childContains);
}
}
if (condition && this.options.visible !== undefined) {
if (isVisible(el) !== this.options.visible) {
condition = false;
}
}
if (condition && this.options.after) {
const afterContains = new Contains(this.options.after[0], {
...this.options.after[1],
target,
});
const afterEl = afterContains.runOnce(`as "after"`, {
executeOnSuccess: false,
})?.[0];
if (
!afterEl ||
!(el.compareDocumentPosition(afterEl) & Node.DOCUMENT_POSITION_PRECEDING)
) {
condition = false;
}
this.childrenContains.push(afterContains);
}
if (condition && this.options.before) {
const beforeContains = new Contains(this.options.before[0], {
...this.options.before[1],
target,
});
const beforeEl = beforeContains.runOnce(`as "before"`, {
executeOnSuccess: false,
})?.[0];
if (
!beforeEl ||
!(el.compareDocumentPosition(beforeEl) & Node.DOCUMENT_POSITION_FOLLOWING)
) {
condition = false;
}
this.childrenContains.push(beforeContains);
}
return condition;
});
if (
this.options.scroll !== undefined &&
this.scrollListeners &&
baseRes.length === this.options.count &&
res.length !== this.options.count
) {
for (const el of baseRes) {
if (!this.scrollListeners.has(el)) {
this.scrollListeners.add(el);
el.addEventListener("scroll", this.onScroll);
}
}
}
return res;
}
/**
* Returns the found element that should act as the target (parent) for the
* main selector.
* If undefined is returned it means the parent cannot be found.
*
* @returns {HTMLElement|undefined}
*/
selectParent() {
if (this.options.parent) {
this.parentContains = new Contains(this.options.parent[0], {
...this.options.parent[1],
target: this.options.target,
});
return this.parentContains.runOnce(`as parent`, { executeOnSuccess: false })?.[0];
}
return this.options.target;
}
}
/**
* Waits until `count` elements matching the given `selector` are present in
* `options.target`.
*
* @param {import("@odoo/hoot-dom").Target} selector
* @param {ContainsOptions} [options]
* @returns {Promise}
*/
export async function contains(selector, options) {
await new Contains(selector, options).run();
}

View file

@ -0,0 +1,222 @@
import { addLink, parseAndTransform } from "@mail/utils/common/format";
import { useSequential } from "@mail/utils/common/hooks";
import {
contains,
defineMailModels,
insertText,
openDiscuss,
start,
startServer,
} from "./mail_test_helpers";
import { describe, expect, test } from "@odoo/hoot";
import { press } from "@odoo/hoot-dom";
import { markup } from "@odoo/owl";
describe.current.tags("desktop");
defineMailModels();
test("add_link utility function", () => {
const testInputs = {
"http://admin:password@example.com:8/%2020": true,
"https://admin:password@example.com/test": true,
"www.example.com:8/test": true,
"https://127.0.0.5:8069": true,
"www.127.0.0.5": false,
"should.notmatch": false,
"fhttps://test.example.com/test": false,
"https://www.transifex.com/odoo/odoo-11/translate/#fr/lunch?q=text%3A'La+Tartiflette'": true,
"https://www.transifex.com/odoo/odoo-11/translate/#fr/$/119303430?q=text%3ATartiflette": true,
"https://tenor.com/view/chỗgiặt-dog-smile-gif-13860250": true,
"http://www.boîtenoire.be": true,
"https://github.com/odoo/enterprise/compare/16.0...odoo-dev:enterprise:16.0-voip-fix_demo_data-tsm?expand=1": true,
"https://github.com/odoo/enterprise/compare/16.0...16.0-voip-fix_demo_data-tsm?expand=1": true,
"https://github.com/odoo/enterprise/compare/16.0...chỗgiặt-voip-fix_demo_data-tsm?expand=1": true,
"https://github.com/odoo/enterprise/compare/chỗgiặt...chỗgiặt-voip-fix_demo_data-tsm?expand=1": true,
"https://github.com/odoo/enterprise/compare/@...}-voip-fix_demo_data-tsm?expand=1": true,
"https://x.com": true,
};
for (const [content, willLinkify] of Object.entries(testInputs)) {
const output = parseAndTransform(content, addLink);
if (willLinkify) {
expect(output.indexOf("<a ")).toBe(0);
expect(output.indexOf("</a>")).toBe(output.length - 4);
} else {
expect(output.indexOf("<a ")).toBe(-1);
}
}
});
test("addLink: utility function and special entities", () => {
const testInputs = [
// textContent not unescaped
[
markup`<p>https://example.com/?&amp;currency_id</p>`,
'<p><a target="_blank" rel="noreferrer noopener" href="https://example.com/?&amp;currency_id">https://example.com/?&amp;currency_id</a></p>',
],
// entities not unescaped
[markup`&amp; &amp;amp; &gt; &lt;`, "&amp; &amp;amp; &gt; &lt;"],
// > and " not linkified since they are not in URL regex
[
markup`<p>https://example.com/&gt;</p>`,
'<p><a target="_blank" rel="noreferrer noopener" href="https://example.com/">https://example.com/</a>&gt;</p>',
],
[
markup`<p>https://example.com/"hello"&gt;</p>`,
'<p><a target="_blank" rel="noreferrer noopener" href="https://example.com/">https://example.com/</a>"hello"&gt;</p>',
],
// & and ' linkified since they are in URL regex
[
markup`<p>https://example.com/&amp;hello</p>`,
'<p><a target="_blank" rel="noreferrer noopener" href="https://example.com/&amp;hello">https://example.com/&amp;hello</a></p>',
],
[
markup`<p>https://example.com/'yeah'</p>`,
'<p><a target="_blank" rel="noreferrer noopener" href="https://example.com/\'yeah\'">https://example.com/\'yeah\'</a></p>',
],
[markup`<p>:'(</p>`, "<p>:'(</p>"],
[markup`:'(`, ":&#x27;("],
["<p>:'(</p>", "&lt;p&gt;:&#x27;(&lt;/p&gt;"],
[":'(", ":&#x27;("],
[markup`<3`, "&lt;3"],
[markup`&lt;3`, "&lt;3"],
["<3", "&lt;3"],
// Already encoded url should not be encoded twice
[
markup`https://odoo.com/%5B%5D`,
`<a target="_blank" rel="noreferrer noopener" href="https://odoo.com/%5B%5D">https://odoo.com/[]</a>`,
],
];
for (const [content, result] of testInputs) {
const output = parseAndTransform(content, addLink);
expect(output).toBeInstanceOf(markup().constructor);
expect(output.toString()).toBe(result);
}
});
test("addLink: linkify inside text node (1 occurrence)", async () => {
const content = markup`<p>some text https://somelink.com</p>`;
const linkified = parseAndTransform(content, addLink);
expect(linkified.startsWith("<p>some text <a")).toBe(true);
expect(linkified.endsWith("</a></p>")).toBe(true);
// linkify may add some attributes. Since we do not care of their exact
// stringified representation, we continue deeper assertion with query
// selectors.
const fragment = document.createDocumentFragment();
const div = document.createElement("div");
fragment.appendChild(div);
div.innerHTML = linkified;
expect(div).toHaveText("some text https://somelink.com");
await contains("a", { target: div });
expect(div.querySelector(":scope a")).toHaveText("https://somelink.com");
});
test("addLink: linkify inside text node (2 occurrences)", () => {
// linkify may add some attributes. Since we do not care of their exact
// stringified representation, we continue deeper assertion with query
// selectors.
const content = markup(
"<p>some text https://somelink.com and again https://somelink2.com ...</p>"
);
const linkified = parseAndTransform(content, addLink);
const fragment = document.createDocumentFragment();
const div = document.createElement("div");
fragment.appendChild(div);
div.innerHTML = linkified;
expect(div).toHaveText("some text https://somelink.com and again https://somelink2.com ...");
expect(div.querySelectorAll(":scope a")).toHaveCount(2);
expect(div.querySelectorAll(":scope a")[0]).toHaveText("https://somelink.com");
expect(div.querySelectorAll(":scope a")[1]).toHaveText("https://somelink2.com");
});
test("url", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
await start();
await openDiscuss(channelId);
// see: https://www.ietf.org/rfc/rfc1738.txt
const messageBody = "https://odoo.com?test=~^|`{}[]#";
await insertText(".o-mail-Composer-input", messageBody);
await press("Enter");
await contains(`.o-mail-Message a:contains(${messageBody})`);
});
test("url with comma at the end", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
await start();
await openDiscuss(channelId);
const messageBody = "Go to https://odoo.com, it's great!";
await insertText(".o-mail-Composer-input", messageBody);
await press("Enter");
await contains(".o-mail-Message a:contains(https://odoo.com)");
await contains(`.o-mail-Message-content:contains(${messageBody}`);
});
test("url with dot at the end", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
await start();
await openDiscuss(channelId);
const messageBody = "Go to https://odoo.com. It's great!";
await insertText(".o-mail-Composer-input", messageBody);
await press("Enter");
await contains(".o-mail-Message a:contains(https://odoo.com)");
await contains(`.o-mail-Message-content:contains(${messageBody})`);
});
test("url with semicolon at the end", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
await start();
await openDiscuss(channelId);
const messageBody = "Go to https://odoo.com; it's great!";
await insertText(".o-mail-Composer-input", messageBody);
await press("Enter");
await contains(".o-mail-Message a:contains(https://odoo.com)");
await contains(`.o-mail-Message-content:contains(${messageBody})`);
});
test("url with ellipsis at the end", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
await start();
await openDiscuss(channelId);
const messageBody = "Go to https://odoo.com... it's great!";
await insertText(".o-mail-Composer-input", messageBody);
await press("Enter");
await contains(".o-mail-Message a:contains(https://odoo.com)");
await contains(`.o-mail-Message-content:contains(${messageBody})`);
});
test("url with number in subdomain", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
await start();
await openDiscuss(channelId);
const messageBody = "https://www.45017478-master-all.runbot134.odoo.com/odoo";
await insertText(".o-mail-Composer-input", messageBody);
await press("Enter");
await contains(
".o-mail-Message a:contains(https://www.45017478-master-all.runbot134.odoo.com/odoo)"
);
});
test("isSequential doesn't execute intermediate call.", async () => {
const sequential = useSequential();
let index = 0;
const sequence = () => {
index++;
const i = index;
return sequential(async () => {
expect.step(i.toString());
return new Promise((r) => setTimeout(() => r(i), 1));
});
};
const result = await Promise.all([sequence(), sequence(), sequence(), sequence(), sequence()]);
expect(result).toEqual([1, undefined, undefined, undefined, 5]);
expect.verifySteps(["1", "5"]);
});

View file

@ -0,0 +1,514 @@
import {
click,
contains,
defineMailModels,
hover,
insertText,
onRpcBefore,
openDiscuss,
start,
startServer,
} from "@mail/../tests/mail_test_helpers";
import { describe, expect, test } from "@odoo/hoot";
import { asyncStep, waitForSteps, Command, serverState } from "@web/../tests/web_test_helpers";
import { press } from "@odoo/hoot-dom";
import { rpc } from "@web/core/network/rpc";
describe.current.tags("desktop");
defineMailModels();
test("auto layout with link preview list", async () => {
const pyEnv = await startServer();
const linkPreviewId = pyEnv["mail.link.preview"].create({
og_description: "test description",
og_image: "https://c.tenor.com/B_zYdea4l-4AAAAC/yay-minions.gif",
og_mimetype: "image/gif",
og_title: "Yay Minions GIF - Yay Minions Happiness - Discover & Share GIFs",
og_type: "video.other",
source_url: "https://tenor.com/view/yay-minions-happiness-happy-excited-gif-15324023",
});
const channelId = pyEnv["discuss.channel"].create({ name: "wololo" });
pyEnv["mail.message"].create({
body: "not empty",
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
message_link_preview_ids: [Command.create({ link_preview_id: linkPreviewId })],
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-Message .o-mail-LinkPreviewList");
});
test("auto layout with link preview as gif", async () => {
const pyEnv = await startServer();
const linkPreviewId = pyEnv["mail.link.preview"].create({
og_description: "test description",
og_image: "https://c.tenor.com/B_zYdea4l-4AAAAC/yay-minions.gif",
og_mimetype: "image/gif",
og_title: "Yay Minions GIF - Yay Minions Happiness - Discover & Share GIFs",
og_type: "video.other",
source_url: "https://tenor.com/view/yay-minions-happiness-happy-excited-gif-15324023",
});
const channelId = pyEnv["discuss.channel"].create({ name: "wololo" });
pyEnv["mail.message"].create({
body: "not empty",
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
message_link_preview_ids: [Command.create({ link_preview_id: linkPreviewId })],
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-LinkPreviewImage");
});
test("simplest card layout", async () => {
const pyEnv = await startServer();
const linkPreviewId = pyEnv["mail.link.preview"].create({
og_description: "Description",
og_title: "Article title",
og_type: "article",
source_url: "https://www.odoo.com",
});
const channelId = pyEnv["discuss.channel"].create({ name: "wololo" });
pyEnv["mail.message"].create({
body: "not empty",
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
message_link_preview_ids: [Command.create({ link_preview_id: linkPreviewId })],
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-LinkPreviewCard");
await contains(".o-mail-LinkPreviewCard h6", { text: "Article title" });
await contains(".o-mail-LinkPreviewCard p", { text: "Description" });
});
test("simplest card layout with image", async () => {
const pyEnv = await startServer();
const linkPreviewId = pyEnv["mail.link.preview"].create({
og_description: "Description",
og_image: "https://c.tenor.com/B_zYdea4l-4AAAAC/yay-minions.gif",
og_title: "Article title",
og_type: "article",
source_url: "https://www.odoo.com",
});
const channelId = pyEnv["discuss.channel"].create({ name: "wololo" });
pyEnv["mail.message"].create({
body: "not empty",
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
message_link_preview_ids: [Command.create({ link_preview_id: linkPreviewId })],
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-LinkPreviewCard");
await contains(".o-mail-LinkPreviewCard h6", { text: "Article title" });
await contains(".o-mail-LinkPreviewCard p", { text: "Description" });
await contains(".o-mail-LinkPreviewCard img");
});
test("Link preview video layout", async () => {
const pyEnv = await startServer();
const linkPreviewId = pyEnv["mail.link.preview"].create({
og_description: "Description",
og_image: "https://c.tenor.com/B_zYdea4l-4AAAAC/yay-minions.gif",
og_title: "video title",
og_type: "video.other",
source_url: "https://www.odoo.com",
});
const channelId = pyEnv["discuss.channel"].create({ name: "wololo" });
pyEnv["mail.message"].create({
body: "not empty",
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
message_link_preview_ids: [Command.create({ link_preview_id: linkPreviewId })],
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-LinkPreviewVideo");
await contains(".o-mail-LinkPreviewVideo h6", { text: "video title" });
await contains(".o-mail-LinkPreviewVideo p", { text: "Description" });
await contains(".o-mail-LinkPreviewVideo-overlay");
});
test("Link preview image layout", async () => {
const pyEnv = await startServer();
const linkPreviewId = pyEnv["mail.link.preview"].create({
image_mimetype: "image/jpg",
source_url:
"https://upload.wikimedia.org/wikipedia/commons/thumb/4/41/Siberischer_tiger_de_edit02.jpg/290px-Siberischer_tiger_de_edit02.jpg",
});
const channelId = pyEnv["discuss.channel"].create({ name: "wololo" });
pyEnv["mail.message"].create({
body: "not empty",
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
message_link_preview_ids: [Command.create({ link_preview_id: linkPreviewId })],
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-LinkPreviewImage");
});
test("Remove link preview Gif", async () => {
const pyEnv = await startServer();
const linkPreviewId = pyEnv["mail.link.preview"].create({
og_description: "test description",
og_image: "https://c.tenor.com/B_zYdea4l-4AAAAC/yay-minions.gif",
og_mimetype: "image/gif",
og_title: "Yay Minions GIF - Yay Minions Happiness - Discover & Share GIFs",
og_type: "video.other",
source_url: "https://tenor.com/view/yay-minions-happiness-happy-excited-gif-15324023",
});
const channelId = pyEnv["discuss.channel"].create({ name: "wololo" });
pyEnv["mail.message"].create({
body: "not empty",
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
message_link_preview_ids: [Command.create({ link_preview_id: linkPreviewId })],
});
await start();
await openDiscuss(channelId);
await click(".o-mail-LinkPreviewImage button[aria-label='Remove']");
await contains("p", { text: "Do you really want to delete this preview?" });
await click(".modal-footer button", { text: "Delete" });
await contains(".o-mail-LinkPreviewImage", { count: 0 });
});
test("Remove link preview card", async () => {
const pyEnv = await startServer();
const linkPreviewId = pyEnv["mail.link.preview"].create({
og_description: "Description",
og_title: "Article title",
og_type: "article",
source_url: "https://www.odoo.com",
});
const channelId = pyEnv["discuss.channel"].create({ name: "wololo" });
pyEnv["mail.message"].create({
body: "not empty",
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
message_link_preview_ids: [Command.create({ link_preview_id: linkPreviewId })],
});
await start();
await openDiscuss(channelId);
await click(".o-mail-LinkPreviewCard button[aria-label='Remove']");
await contains("p", { text: "Do you really want to delete this preview?" });
await click(".modal-footer button", { text: "Delete" });
await contains(".o-mail-LinkPreviewCard", { count: 0 });
});
test("Remove link preview video", async () => {
const pyEnv = await startServer();
const linkPreviewId = pyEnv["mail.link.preview"].create({
og_description: "Description",
og_image: "https://c.tenor.com/B_zYdea4l-4AAAAC/yay-minions.gif",
og_title: "video title",
og_type: "video.other",
source_url: "https://www.odoo.com",
});
const channelId = pyEnv["discuss.channel"].create({ name: "wololo" });
pyEnv["mail.message"].create({
body: "not empty",
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
message_link_preview_ids: [Command.create({ link_preview_id: linkPreviewId })],
});
await start();
await openDiscuss(channelId);
await click(".o-mail-LinkPreviewVideo button[aria-label='Remove']");
await contains("p", { text: "Do you really want to delete this preview?" });
await click(".modal-footer button", { text: "Delete" });
await contains(".o-mail-LinkPreviewVideo", { count: 0 });
});
test("Remove link preview image", async () => {
const pyEnv = await startServer();
const linkPreviewId = pyEnv["mail.link.preview"].create({
image_mimetype: "image/jpg",
source_url:
"https://upload.wikimedia.org/wikipedia/commons/thumb/4/41/Siberischer_tiger_de_edit02.jpg/290px-Siberischer_tiger_de_edit02.jpg",
});
const channelId = pyEnv["discuss.channel"].create({ name: "wololo" });
pyEnv["mail.message"].create({
body: "not empty",
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
message_link_preview_ids: [Command.create({ link_preview_id: linkPreviewId })],
});
await start();
await openDiscuss(channelId);
await click(".o-mail-LinkPreviewImage button[aria-label='Remove']");
await contains("p", { text: "Do you really want to delete this preview?" });
await click(".modal-footer button", { text: "Delete" });
await contains(".o-mail-LinkPreviewImage", { count: 0 });
});
test("No crash on receiving link preview of non-known message", async () => {
const pyEnv = await startServer();
const linkPreviewId = pyEnv["mail.link.preview"].create({
image_mimetype: "image/jpg",
source_url:
"https://upload.wikimedia.org/wikipedia/commons/thumb/4/41/Siberischer_tiger_de_edit02.jpg/290px-Siberischer_tiger_de_edit02.jpg",
});
const channelId = pyEnv["discuss.channel"].create({ name: "wololo" });
const messageId = pyEnv["mail.message"].create({
body: "https://make-link-preview.com",
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
message_link_preview_ids: [Command.create({ link_preview_id: linkPreviewId })],
});
const messageLinkPreviewId = pyEnv["mail.message.link.preview"].create({
message_id: messageId,
link_preview_id: linkPreviewId,
});
await start();
await openDiscuss();
rpc("/mail/link_preview", { message_id: messageId });
rpc("/mail/link_preview/hide", { message_link_preview_ids: [messageLinkPreviewId] });
expect(true).toBe(true, { message: "no assertions" });
});
test("Squash the message and the link preview when the link preview is an image and the link is the only text in the message", async () => {
const pyEnv = await startServer();
const linkPreviewId = pyEnv["mail.link.preview"].create({
image_mimetype: "image/jpg",
source_url:
"https://upload.wikimedia.org/wikipedia/commons/thumb/4/41/Siberischer_tiger_de_edit02.jpg/290px-Siberischer_tiger_de_edit02.jpg",
});
const channelId = pyEnv["discuss.channel"].create({ name: "wololo" });
pyEnv["mail.message"].create({
body: "<a href='linkPreviewLink'>http://linkPreview</a>",
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
message_link_preview_ids: [Command.create({ link_preview_id: linkPreviewId })],
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-LinkPreviewImage");
await contains(".o-mail-Message-bubble", { count: 0 });
});
test("Link preview and message should not be squashed when the link preview is not an image", async () => {
const pyEnv = await startServer();
const linkPreviewId = pyEnv["mail.link.preview"].create({
og_description: "Description",
og_title: "Article title",
og_type: "article",
source_url: "https://www.odoo.com",
});
const channelId = pyEnv["discuss.channel"].create({ name: "wololo" });
pyEnv["mail.message"].create({
body: "<a href='linkPreviewLink'>http://linkPreview</a>",
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
message_link_preview_ids: [Command.create({ link_preview_id: linkPreviewId })],
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-Message-bubble");
});
test("Link preview and message should not be squashed when there is more than the link in the message", async () => {
const pyEnv = await startServer();
const linkPreviewId = pyEnv["mail.link.preview"].create({
image_mimetype: "image/jpg",
source_url:
"https://upload.wikimedia.org/wikipedia/commons/thumb/4/41/Siberischer_tiger_de_edit02.jpg/290px-Siberischer_tiger_de_edit02.jpg",
});
const channelId = pyEnv["discuss.channel"].create({ name: "wololo" });
pyEnv["mail.message"].create({
body: "<a href='linkPreviewLink'>http://linkPreview</a> not empty",
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
message_link_preview_ids: [Command.create({ link_preview_id: linkPreviewId })],
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-Message-bubble");
});
test("Sending message with link preview URL should show a link preview card", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "wololo" });
await start();
await openDiscuss(channelId);
await insertText(".o-mail-Composer-input", "https://make-link-preview.com");
await press("Enter");
await contains(".o-mail-LinkPreviewCard");
});
test("Delete all link previews at once", async () => {
const pyEnv = await startServer();
const [linkPreviewId_1, linkPreviewId_2] = pyEnv["mail.link.preview"].create([
{
og_description: "Description",
og_title: "Article title 1",
og_type: "article",
source_url: "https://www.odoo.com",
},
{
image_mimetype: "image/jpg",
source_url:
"https://upload.wikimedia.org/wikipedia/commons/thumb/4/41/Siberischer_tiger_de_edit02.jpg/290px-Siberischer_tiger_de_edit02.jpg",
},
]);
const channelId = pyEnv["discuss.channel"].create({ name: "wololo" });
pyEnv["mail.message"].create({
body: "not empty",
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
message_link_preview_ids: [
Command.create({ link_preview_id: linkPreviewId_1 }),
Command.create({ link_preview_id: linkPreviewId_2 }),
],
});
await start();
await openDiscuss(channelId);
await click(".o-mail-LinkPreviewCard button[aria-label='Remove']");
await click(".modal-footer button", { text: "Delete all previews" });
await contains(".o-mail-LinkPreviewCard", { count: 0 });
await contains(".o-mail-LinkPreviewImage", { count: 0 });
});
test("link preview request is only made when message contains URL", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "Sales" });
onRpcBefore("/mail/link_preview", () => asyncStep("/mail/link_preview"));
await start();
await openDiscuss(channelId);
await insertText(".o-mail-Composer-input", "Hello, this message does not contain any link");
await press("Enter");
await contains(".o-mail-Message", {
text: "Hello, this message does not contain any link",
});
await waitForSteps([]);
await insertText(".o-mail-Composer-input", "#");
await click(".o-mail-NavigableList-item", { text: "Sales" });
await press("Enter");
await contains(".o-mail-Message", { text: "Sales" });
await waitForSteps([]);
await insertText(".o-mail-Composer-input", "https://www.odoo.com");
await press("Enter");
await waitForSteps(["/mail/link_preview"]);
});
test("youtube and gdrive videos URL are embed", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
const [linkPreviewId_1, linkPreviewId_2] = pyEnv["mail.link.preview"].create([
{
og_title: "vokoscreenNG-2024-08-22_13-56-37.mkv",
og_type: "article",
source_url: "https://drive.google.com/file/d/195a8fSNxwmkfs9sDS7OCB2nX03iFr21P/view",
},
{
og_title: "Cinematic",
og_type: "video",
source_url: "https://www.youtube.com/watch?v=9bZkp7q19f0",
},
]);
pyEnv["mail.message"].create([
{
body: "GDrive video preview",
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
message_link_preview_ids: [Command.create({ link_preview_id: linkPreviewId_1 })],
},
{
body: "YT video preview",
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
message_link_preview_ids: [Command.create({ link_preview_id: linkPreviewId_2 })],
},
]);
await start();
await openDiscuss(channelId);
await click(".o-mail-LinkPreviewVideo[data-provider=google-drive] .fa-play");
await contains(
"iframe[data-src='https://drive.google.com/file/d/195a8fSNxwmkfs9sDS7OCB2nX03iFr21P/preview']",
{ parent: [".o-mail-LinkPreviewVideo[data-provider=google-drive]"] }
);
await click(".o-mail-LinkPreviewVideo[data-provider=youtube] .fa-play");
await contains("iframe[data-src='https://www.youtube.com/embed/9bZkp7q19f0?autoplay=1']", {
parent: [".o-mail-LinkPreviewVideo[data-provider=youtube]"],
});
});
test("Internal user can't delete others preview", async () => {
const pyEnv = await startServer();
const [linkPreviewId_1, linkPreviewId_2] = pyEnv["mail.link.preview"].create([
{
og_description: "Description",
og_title: "Article title 1",
og_type: "article",
source_url: "https://www.odoo.com/",
},
{
og_description: "Description",
og_title: "Article title 2",
og_type: "article",
source_url: "https://example.com",
},
]);
const partnerId = pyEnv["res.partner"].create({ name: "Test User" });
const userId = pyEnv["res.users"].create({
partner_id: partnerId,
login: "testUser",
password: "testUser",
});
const channelId = pyEnv["discuss.channel"].create({
name: "wololo",
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
});
pyEnv["mail.message"].create([
{
body: "msg-1",
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
author_id: serverState.partnerId,
message_link_preview_ids: [Command.create({ link_preview_id: linkPreviewId_1 })],
},
{
body: "msg-2",
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
author_id: partnerId,
message_link_preview_ids: [Command.create({ link_preview_id: linkPreviewId_2 })],
},
]);
await start({ authenticateAs: pyEnv["res.users"].read(userId)[0] });
await openDiscuss(channelId);
await hover(".o-mail-Message:contains('msg-2') .o-mail-LinkPreviewCard");
await contains(
".o-mail-Message:contains('msg-2') .o-mail-LinkPreviewCard button[aria-label='Remove']"
);
await hover(".o-mail-Message:contains('msg-1') .o-mail-LinkPreviewCard");
await contains(
".o-mail-Message:contains('msg-1') .o-mail-LinkPreviewCard button[aria-label='Remove']",
{ count: 0 }
);
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,269 @@
import { insertText as htmlInsertText } from "@html_editor/../tests/_helpers/user_actions";
import {
click,
contains,
defineMailModels,
openDiscuss,
start,
startServer,
openFormView,
} from "@mail/../tests/mail_test_helpers";
import { describe, test } from "@odoo/hoot";
import { queryFirst } from "@odoo/hoot-dom";
import { disableAnimations } from "@odoo/hoot-mock";
import { getService, serverState } from "@web/../tests/web_test_helpers";
import { deserializeDateTime } from "@web/core/l10n/dates";
import { getOrigin } from "@web/core/utils/urls";
describe.current.tags("desktop");
defineMailModels();
test("click on message in reply to highlight the parent message", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "general" });
const messageId = pyEnv["mail.message"].create({
body: "Hey lol",
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
});
pyEnv["mail.message"].create({
body: "Reply to Hey",
message_type: "comment",
model: "discuss.channel",
parent_id: messageId,
res_id: channelId,
});
await start();
await openDiscuss(channelId);
await click(".o-mail-MessageInReply-message", {
parent: [".o-mail-Message", { text: "Reply to Hey" }],
});
await contains(".o-mail-Message.o-highlighted .o-mail-Message-content", { text: "Hey lol" });
});
test("click on message in reply to scroll to the parent message", async () => {
disableAnimations();
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "general" });
const [oldestMessageId] = pyEnv["mail.message"].create(
Array(20)
.fill(0)
.map(() => ({
body: "Non Empty Body ".repeat(25),
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
}))
);
pyEnv["mail.message"].create({
body: "Response to first message",
message_type: "comment",
model: "discuss.channel",
parent_id: oldestMessageId,
res_id: channelId,
});
await start();
await openDiscuss(channelId);
await click(".o-mail-MessageInReply-message", {
parent: [".o-mail-Message", { text: "Response to first message" }],
});
await contains(":nth-child(1 of .o-mail-Message)", { visible: true });
});
test("reply shows correct author avatar", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "general" });
const messageId = pyEnv["mail.message"].create({
body: "Hey there",
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
});
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
const partner = pyEnv["res.partner"].search_read([["id", "=", serverState.partnerId]])[0];
pyEnv["mail.message"].create({
body: "Howdy",
message_type: "comment",
model: "discuss.channel",
author_id: partnerId,
parent_id: messageId,
res_id: channelId,
});
await start();
await openDiscuss(channelId);
await contains(
`.o-mail-MessageInReply-avatar[data-src='${`${getOrigin()}/web/image/res.partner/${
serverState.partnerId
}/avatar_128?unique=${deserializeDateTime(partner.write_date).ts}`}`
);
});
test("click on message in reply highlights original message", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "general" });
const messageId = pyEnv["mail.message"].create({
body: "",
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
});
pyEnv["mail.message"].create({
body: "Response to deleted message",
message_type: "comment",
model: "discuss.channel",
parent_id: messageId,
res_id: channelId,
});
await start();
await openDiscuss(channelId);
await click(
".o-mail-Message:contains('Response to deleted message') .o-mail-MessageInReply:contains('Original message was deleted') .cursor-pointer"
);
await contains(".o-mail-Message.o-highlighted:contains('This message has been removed')");
});
test("can reply to logged note in chatter", async () => {
const pyEnv = await startServer();
const partnerBId = pyEnv["res.partner"].create({ name: "Partner B" });
const channelId = pyEnv["discuss.channel"].create({ name: "general" });
pyEnv["mail.message"].create([
{
author_id: partnerBId,
body: "Test message from B",
model: "res.partner",
res_id: serverState.partnerId,
subtype_id: pyEnv["mail.message.subtype"].search([
["subtype_xmlid", "=", "mail.mt_note"],
])[0],
},
{
author_id: serverState.partnerId,
body: "Another msg",
model: "discuss.channel",
message_type: "comment",
res_id: channelId,
},
]);
await start();
await openDiscuss(channelId);
await click(".o-mail-Message [title='Expand']");
await contains(".o-dropdown-item:contains('Reply')");
await openFormView("res.partner", serverState.partnerId);
await click(".o-mail-Message:contains('Test message from B') [title='Reply']");
await contains("button.active", { text: "Log note" });
await contains(".o-mail-Composer.o-focused .o-mail-Composer-input", { value: "@Partner B " });
await click(".o-mail-Composer-send:enabled");
await contains(".o-mail-Message a.o_mail_redirect", { text: "@Partner B" });
await contains(".o-mail-Message:contains('@Partner B') [title='Edit']");
await contains(".o-mail-Message:contains('@Partner B') [title='Reply']", { count: 0 });
await click(".o-mail-Message:contains('@Partner B') [title='Expand']");
await contains(".o-dropdown-item:contains('Delete')");
await contains(".o-dropdown-item:contains('Reply')", { count: 0 });
});
test.tags("html composer");
test("reply to logged note in chatter keeps prefilled mention in html composer", async () => {
const pyEnv = await startServer();
const partnerBId = pyEnv["res.partner"].create({ name: "Partner B" });
pyEnv["mail.message"].create({
author_id: partnerBId,
body: "Test message from B",
model: "res.partner",
res_id: serverState.partnerId,
subtype_id: pyEnv["mail.message.subtype"].search([
["subtype_xmlid", "=", "mail.mt_note"],
])[0],
});
await start();
getService("mail.composer").setHtmlComposer();
await openFormView("res.partner", serverState.partnerId);
await click(".o-mail-Message:contains('Test message from B') [title='Reply']");
await contains("button.active:text('Log note')");
await contains(".o-mail-Composer.o-focused .o-mail-Composer-html.odoo-editor-editable");
await contains(".o-mail-Composer-html.odoo-editor-editable a.o_mail_redirect:text('@Partner B')");
const editor = {
document,
editable: queryFirst(".o-mail-Composer-html.odoo-editor-editable"),
};
await htmlInsertText(editor, "Hello");
await contains(".o-mail-Composer-send:enabled");
await click(".o-mail-Composer-send:enabled");
await contains(".o-mail-Message:contains('Hello') a.o_mail_redirect:text('@Partner B')");
});
test("Replying to a message containing line breaks should be correctly inlined", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "general" });
const messageId = pyEnv["mail.message"].create({
body: "<p>Message first line.<br>Message second line.<br>Message third line.</p>",
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
});
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
pyEnv["mail.message"].create({
body: "Howdy",
message_type: "comment",
model: "discuss.channel",
author_id: partnerId,
parent_id: messageId,
res_id: channelId,
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-MessageInReply-message", {
text: "Message first line. Message second line. Message third line.",
});
});
test("reply with only attachment shows parent message context", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "general" });
const originalMessageId = pyEnv["mail.message"].create({
body: "Original message content",
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
});
const attachmentId = pyEnv["ir.attachment"].create({
name: "test_image.png",
mimetype: "image/png",
});
pyEnv["mail.message"].create({
attachment_ids: [attachmentId],
body: "",
message_type: "comment",
model: "discuss.channel",
parent_id: originalMessageId,
res_id: channelId,
});
await start();
await openDiscuss(channelId);
await contains(".o-mail-MessageInReply-message", {
text: "Original message content",
});
});
test("replying to a note restores focus on an already open composer", async () => {
const pyEnv = await startServer();
const partnerBId = pyEnv["res.partner"].create({ name: "Partner B" });
pyEnv["mail.message"].create({
author_id: partnerBId,
body: "Test message from B",
model: "res.partner",
res_id: serverState.partnerId,
subtype_id: pyEnv["mail.message.subtype"].search([
["subtype_xmlid", "=", "mail.mt_note"],
])[0],
});
await start();
await openFormView("res.partner", serverState.partnerId);
await click("button:not(.active):text('Log note')");
await contains(".o-mail-Composer.o-focused");
queryFirst(".o-mail-Composer-input").blur();
await contains(".o-mail-Composer.o-focused", { count: 0 });
await click(".o-mail-Message-actions [title='Reply']");
await contains(".o-mail-Composer.o-focused");
});

View file

@ -0,0 +1,138 @@
import {
click,
contains,
defineMailModels,
insertText,
listenStoreFetch,
openDiscuss,
openFormView,
start,
startServer,
waitStoreFetch,
} from "@mail/../tests/mail_test_helpers";
import { describe, expect, test } from "@odoo/hoot";
import { press } from "@odoo/hoot-dom";
import { Command, getService, serverState, withUser } from "@web/../tests/web_test_helpers";
import { rpc } from "@web/core/network/rpc";
describe.current.tags("desktop");
defineMailModels();
test("Receiving a new message out of discuss app should open a chat bubble", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Dumbledore" });
const userId = pyEnv["res.users"].create({ partner_id: partnerId });
const channelId = pyEnv["discuss.channel"].create({
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
channel_type: "chat",
});
listenStoreFetch("init_messaging");
await start();
await waitStoreFetch("init_messaging");
// send after init_messaging because bus subscription is done after init_messaging
// simulate receving new message
withUser(userId, () =>
rpc("/mail/message/post", {
post_data: { body: "Magic!", message_type: "comment" },
thread_id: channelId,
thread_model: "discuss.channel",
})
);
await contains(".o-mail-ChatBubble[name='Dumbledore']");
});
test("Show conversations with new message in chat hub (outside of discuss app)", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Dumbledore" });
const userId = pyEnv["res.users"].create({ partner_id: partnerId });
const [chatId, groupChatId] = pyEnv["discuss.channel"].create([
{
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
channel_type: "chat",
},
{
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
channel_type: "group",
name: "GroupChat",
},
]);
await start();
getService("bus_service").subscribe("discuss.channel/new_message", () =>
expect.step("discuss.channel/new_message")
);
// simulate receiving new message (chat, outside discuss app)
await withUser(userId, () =>
rpc("/mail/message/post", {
post_data: { body: "Chat Message 1", message_type: "comment" },
thread_id: chatId,
thread_model: "discuss.channel",
})
);
await expect.waitForSteps(["discuss.channel/new_message"]);
await contains(".o-mail-ChatBubble .badge:contains(1)", { count: 1 });
await click(".o-mail-ChatBubble[name='Dumbledore']");
await contains(".o-mail-ChatWindow-header:contains('Dumbledore')");
await contains(".o-mail-Message:contains('Chat Message 1')");
await contains(".badge", { count: 0 });
await click(".o-mail-ChatWindow [title*='Close Chat Window']");
// simulate receiving new message (group chat, outside discuss app)
await withUser(userId, () =>
rpc("/mail/message/post", {
post_data: { body: "GroupChat Message", message_type: "comment" },
thread_id: groupChatId,
thread_model: "discuss.channel",
})
);
await expect.waitForSteps(["discuss.channel/new_message"]);
await contains(".o-mail-ChatBubble[name='GroupChat']");
await openDiscuss();
await contains(".o-mail-Discuss[data-active]");
// simulate receiving new message (chat, inside discuss app)
await contains(".o-mail-DiscussSidebar-item:contains('Dumbledore') .badge", { count: 0 });
await withUser(userId, () =>
rpc("/mail/message/post", {
post_data: { body: "Tricky", message_type: "comment" },
thread_id: chatId,
thread_model: "discuss.channel",
})
);
await expect.waitForSteps(["discuss.channel/new_message"]);
await click(".o-mail-DiscussSidebar-item:contains('Dumbledore'):has(.badge:contains(1))");
await contains(".o-mail-Message:contains('Tricky')");
// check no new chat window/bubble while in discuss app
await openFormView("res.partner", partnerId);
await contains(".o-mail-ChatBubble[name='GroupChat']");
await contains(".o-mail-ChatBubble[name='Dumbledore']", { count: 0 });
await contains(".o-mail-ChatWindow-header:contains('Dumbledore')", { count: 0 });
});
test("Posting a message in discuss app should not open a chat window after leaving discuss app", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Dumbledore" });
const channelId = pyEnv["discuss.channel"].create({
channel_member_ids: [
Command.create({ partner_id: serverState.partnerId }),
Command.create({ partner_id: partnerId }),
],
channel_type: "chat",
});
await start();
await openDiscuss(channelId);
await insertText(".o-mail-Composer-input", "test https://www.odoo.com/");
await press("Enter");
// leaving discuss.
await openFormView("res.partner", partnerId);
// weak test, no guarantee that we waited long enough for the potential chat window to open
await contains(".o-mail-ChatWindow", { count: 0, text: "Dumbledore" });
});

View file

@ -0,0 +1,374 @@
import {
click,
contains,
defineMailModels,
start,
startServer,
triggerEvents,
} from "@mail/../tests/mail_test_helpers";
import { rpcWithEnv } from "@mail/utils/common/misc";
import { describe, expect, test } from "@odoo/hoot";
import { mockDate } from "@odoo/hoot-mock";
import {
asyncStep,
Command,
mockService,
serverState,
waitForSteps,
withUser,
} from "@web/../tests/web_test_helpers";
/** @type {ReturnType<import("@mail/utils/common/misc").rpcWithEnv>} */
let rpc;
describe.current.tags("desktop");
defineMailModels();
test("basic layout", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({});
const messageId = pyEnv["mail.message"].create({
date: "2019-01-01 10:30:00",
message_type: "email",
model: "discuss.channel",
res_id: channelId,
});
pyEnv["mail.notification"].create([
{
mail_message_id: messageId,
notification_status: "exception",
notification_type: "email",
},
{
mail_message_id: messageId,
notification_status: "exception",
notification_type: "email",
},
]);
await start();
await click(".o_menu_systray i[aria-label='Messages']");
await contains(".o-mail-NotificationItem", {
contains: [
[".o-mail-NotificationItem-name", { text: "Email Failure: Discussion Channel" }],
[".o-mail-NotificationItem-counter", { text: "2" }],
[".o-mail-NotificationItem-date", { text: "Jan 1" }],
[".o-mail-NotificationItem-text", { text: "An error occurred when sending an email" }],
],
});
});
test("mark as read", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({});
const messageId = pyEnv["mail.message"].create({
message_type: "email",
model: "discuss.channel",
res_id: channelId,
});
pyEnv["mail.notification"].create({
mail_message_id: messageId,
notification_status: "exception",
notification_type: "email",
});
await start();
await click(".o_menu_systray i[aria-label='Messages']");
await triggerEvents(".o-mail-NotificationItem", ["mouseenter"], {
text: "Email Failure: Discussion Channel",
});
await click(".o-mail-NotificationItem-markAsRead", {
parent: [".o-mail-NotificationItem", { text: "Email Failure: Discussion Channel" }],
});
await contains(".o-mail-NotificationItem", {
count: 0,
text: "Email Failure: Discussion Channel",
});
});
test("open non-channel failure", async () => {
const pyEnv = await startServer();
const [partnerId_1, partnerId_2] = pyEnv["res.partner"].create([{}, {}]);
const [messageId_1, messageId_2] = pyEnv["mail.message"].create([
{
message_type: "email",
model: "res.partner",
res_id: partnerId_1,
},
{
message_type: "email",
model: "res.partner",
res_id: partnerId_2,
},
]);
pyEnv["mail.notification"].create([
{
mail_message_id: messageId_1,
notification_status: "exception",
notification_type: "email",
},
{
mail_message_id: messageId_2,
notification_status: "bounce",
notification_type: "email",
},
]);
mockService("action", {
doAction(action) {
asyncStep("do_action");
expect(action.name).toBe("Mail Failures");
expect(action.type).toBe("ir.actions.act_window");
expect(action.view_mode).toBe("kanban,list,form");
expect(JSON.stringify(action.views)).toBe(
JSON.stringify([
[false, "kanban"],
[false, "list"],
[false, "form"],
])
);
expect(action.target).toBe("current");
expect(action.res_model).toBe("res.partner");
expect(JSON.stringify(action.domain)).toBe(
JSON.stringify([["message_has_error", "=", true]])
);
},
});
await start();
await click(".o_menu_systray i[aria-label='Messages']");
await click(".o-mail-NotificationItem");
await waitForSteps(["do_action"]);
});
test("different discuss.channel are not grouped", async () => {
const pyEnv = await startServer();
const [channelId_1, channelId_2] = pyEnv["discuss.channel"].create([
{ name: "Channel_1" },
{ name: "Channel_2" },
]);
const [messageId_1, messageId_2] = pyEnv["mail.message"].create([
{
message_type: "email",
model: "discuss.channel",
res_id: channelId_1,
},
{
message_type: "email",
model: "discuss.channel",
res_id: channelId_2,
},
]);
pyEnv["mail.notification"].create([
{
mail_message_id: messageId_1,
notification_status: "exception",
notification_type: "email",
},
{
mail_message_id: messageId_1,
notification_status: "exception",
notification_type: "email",
},
{
mail_message_id: messageId_2,
notification_status: "bounce",
notification_type: "email",
},
{
mail_message_id: messageId_2,
notification_status: "bounce",
notification_type: "email",
},
]);
await start();
await click(".o_menu_systray i[aria-label='Messages']");
await contains(".o-mail-NotificationItem-text", {
count: 2,
text: "An error occurred when sending an email",
});
});
test("multiple grouped notifications by model", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
const companyId = pyEnv["res.company"].create({});
const [messageId_1, messageId_2] = pyEnv["mail.message"].create([
{
message_type: "email",
model: "res.partner",
res_id: partnerId,
},
{
message_type: "email",
model: "res.company",
res_id: companyId,
},
]);
pyEnv["mail.notification"].create([
{
mail_message_id: messageId_1,
notification_status: "exception",
notification_type: "email",
},
{
mail_message_id: messageId_1,
notification_status: "exception",
notification_type: "email",
},
{
mail_message_id: messageId_2,
notification_status: "bounce",
notification_type: "email",
},
{
mail_message_id: messageId_2,
notification_status: "bounce",
notification_type: "email",
},
]);
await start();
await click(".o_menu_systray i[aria-label='Messages']");
await contains(".o-mail-NotificationItem", { count: 2 });
await contains(".o-mail-NotificationItem-counter", { count: 2, text: "2" });
});
test("non-failure notifications are ignored", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
const messageId = pyEnv["mail.message"].create({
message_type: "email",
model: "res.partner",
res_id: partnerId,
});
pyEnv["mail.notification"].create({
mail_message_id: messageId,
notification_status: "ready",
notification_type: "email",
});
await start();
await click(".o_menu_systray i[aria-label='Messages']");
await contains(".o-mail-NotificationItem", { count: 0 });
});
test("marked as read thread notifications are ordered by last message date", async () => {
const pyEnv = await startServer();
const [channelId_1, channelId_2] = pyEnv["discuss.channel"].create([
{ name: "Channel 2019" },
{ name: "Channel 2020" },
]);
pyEnv["mail.message"].create([
{
body: "not empty",
date: "2019-01-01 00:00:00",
model: "discuss.channel",
res_id: channelId_1,
},
{
body: "not empty",
date: "2020-01-01 00:00:00",
model: "discuss.channel",
res_id: channelId_2,
},
]);
await start();
await click(".o_menu_systray i[aria-label='Messages']");
await contains(".o-mail-NotificationItem", { count: 2 });
await contains(":nth-child(1 of .o-mail-NotificationItem)", { text: "Channel 2020" });
await contains(":nth-child(2 of .o-mail-NotificationItem)", { text: "Channel 2019" });
});
test("thread notifications are re-ordered on receiving a new message", async () => {
mockDate("2023-01-03 12:00:00"); // so that it's after last interest (mock server is in 2019 by default!)
const pyEnv = await startServer();
const bobUserId = pyEnv["res.users"].create({ name: "Bob" });
const bobPartnerId = pyEnv["res.partner"].create({ name: "Bob", user_id: bobUserId.id });
const [channelId_1, channelId_2] = pyEnv["discuss.channel"].create([
{
name: "Channel 2019",
channel_member_ids: [
Command.create({ partner_id: bobPartnerId }),
Command.create({ partner_id: serverState.partnerId }),
],
},
{ name: "Channel 2020" },
]);
pyEnv["mail.message"].create([
{
date: "2019-01-01 00:00:00",
body: "some text",
model: "discuss.channel",
res_id: channelId_1,
},
{
date: "2020-01-01 00:00:00",
body: "some text 2",
model: "discuss.channel",
res_id: channelId_2,
},
]);
const env = await start();
rpc = rpcWithEnv(env);
await click(".o_menu_systray i[aria-label='Messages']");
await contains(".o-mail-NotificationItem", { count: 2 });
await withUser(bobUserId, () =>
rpc("/mail/message/post", {
post_data: {
body: "<p>New message !</p>",
message_type: "comment",
subtype_xmlid: "mail.mt_comment",
},
thread_id: channelId_1,
thread_model: "discuss.channel",
})
);
await contains(":nth-child(1 of .o-mail-NotificationItem)", { text: "Channel 2019" });
await contains(":nth-child(2 of .o-mail-NotificationItem)", { text: "Channel 2020" });
await contains(".o-mail-NotificationItem", { count: 2 });
});
test("messaging menu counter should ignore unread messages in channels that are unpinned", async () => {
mockDate("2023-01-03 12:00:00"); // so that it's after last interest (mock server is in 2019 by default!)
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
pyEnv["discuss.channel"].create({ name: "General" });
const channelId = pyEnv["discuss.channel"].create({
channel_member_ids: [
Command.create({
unpin_dt: "2023-01-01 12:00:00",
last_interest_dt: "2023-01-01 11:00:00",
message_unread_counter: 1,
partner_id: serverState.partnerId,
}),
Command.create({ partner_id: partnerId }),
],
channel_type: "chat",
});
pyEnv["mail.message"].create([
{
model: "discuss.channel",
res_id: channelId,
author_id: partnerId,
message_type: "email",
},
]);
await start();
await contains(".o_menu_systray i[aria-label='Messages']");
await contains(".o-mail-MessagingMenu-counter", { count: 0 });
await click(".o_menu_systray i[aria-label='Messages']"); // fetch channels
await contains(".o-mail-NotificationItem", { text: "General" }); // ensure channels fetched
await contains(".o-mail-MessagingMenu-counter", { count: 0 });
});
test("subtype description should be displayed when body is empty", async () => {
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Partner1" });
const channelId = pyEnv["discuss.channel"].create({ name: "Test" });
const subtypeId = pyEnv["mail.message.subtype"].create({ description: "hello" });
pyEnv["mail.message"].create({
author_id: partnerId,
body: "",
model: "discuss.channel",
res_id: channelId,
subtype_id: subtypeId,
});
await start();
await click(".o_menu_systray i[aria-label='Messages']");
await contains(".o-mail-NotificationItem-text", { text: "Partner1: hello" });
});

View file

@ -0,0 +1,155 @@
import {
mailCanAddMessageReactionMobile,
mailCanCopyTextToClipboardMobile,
} from "@mail/../tests/mail_shared_tests";
import {
SIZES,
assertChatHub,
click,
contains,
defineMailModels,
insertText,
listenStoreFetch,
openDiscuss,
openFormView,
openListView,
patchUiSize,
setupChatHub,
start,
startServer,
waitStoreFetch,
} from "@mail/../tests/mail_test_helpers";
import { LONG_PRESS_DELAY } from "@mail/utils/common/hooks";
import { describe, test } from "@odoo/hoot";
import { advanceTime, pointerDown, press } from "@odoo/hoot-dom";
import { Deferred, mockTouch, mockUserAgent } from "@odoo/hoot-mock";
import { browser } from "@web/core/browser/browser";
import { asyncStep, serverState, waitForSteps } from "@web/../tests/web_test_helpers";
describe.current.tags("mobile");
defineMailModels();
test("auto-select 'Inbox' when discuss had channel as active thread", async () => {
const pyEnv = await startServer();
pyEnv["res.users"].write(serverState.userId, { notification_type: "inbox" });
const channelId = pyEnv["discuss.channel"].create({ name: "test" });
patchUiSize({ size: SIZES.SM });
await start();
await openDiscuss(channelId);
await click(".o-mail-ChatWindow [title*='Close Chat Window']");
await contains(".o-mail-MessagingMenu-tab.active", { text: "Channels" });
await click("button", { text: "Inbox" });
await contains(".o-mail-MessagingMenu-tab.active", { text: "Inbox" });
await contains(".btn-secondary.active", { text: "Inbox" }); // in header
});
test("show loading on initial opening", async () => {
// This could load a lot of data (all pinned conversations)
const def = new Deferred();
listenStoreFetch("channels_as_member", {
async onRpc() {
asyncStep("before channels_as_member");
await def;
},
});
const pyEnv = await startServer();
pyEnv["discuss.channel"].create({ name: "General" });
patchUiSize({ size: SIZES.SM });
await start();
await click(".o_menu_systray i[aria-label='Messages']");
await contains(".o-mail-MessagingMenu .fa.fa-circle-o-notch.fa-spin");
await contains(".o-mail-NotificationItem", { text: "General", count: 0 });
await waitForSteps(["before channels_as_member"]);
def.resolve();
await waitStoreFetch("channels_as_member");
await contains(".o-mail-MessagingMenu .fa.fa-circle-o-notch.fa-spin", { count: 0 });
await contains(".o-mail-NotificationItem", { text: "General" });
});
test("can leave channel in mobile", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
patchUiSize({ size: SIZES.SM });
await start();
await openDiscuss(channelId);
// dropdown requires an extra delay before click (because handler is registered in useEffect)
await contains(".o-mail-ChatWindow-moreActions", { text: "General" });
await click(".o-mail-ChatWindow-moreActions", { text: "General" });
await contains(".o-dropdown-item", { text: "Leave Channel" });
});
test("enter key should create a newline in composer", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
await start();
await openDiscuss(channelId);
await insertText(".o-mail-Composer-input", "Test\n");
await press("Enter");
await insertText(".o-mail-Composer-input", "Other");
await click(".fa-paper-plane-o");
await contains(".o-mail-Message-body:has(br)", { textContent: "TestOther" });
});
test("can add message reaction (mobile)", mailCanAddMessageReactionMobile);
test("can copy text to clipboard (mobile)", mailCanCopyTextToClipboardMobile);
test("Can edit message comment in chatter (mobile)", async () => {
mockTouch(true);
mockUserAgent("android");
patchUiSize({ size: SIZES.SM });
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "TestPartner" });
pyEnv["mail.message"].create({
author_id: serverState.partnerId,
body: "original message",
message_type: "comment",
model: "res.partner",
res_id: partnerId,
});
await start();
await openFormView("res.partner", partnerId);
await contains(".o-mail-Message", { text: "original message" });
await pointerDown(".o-mail-Message", { contains: "original message" });
await advanceTime(LONG_PRESS_DELAY);
await click("button", { text: "Edit" });
await click("button", { text: "Discard editing" });
await contains(".o-mail-Message", { text: "original message" });
await pointerDown(".o-mail-Message", { contains: "original message" });
await advanceTime(LONG_PRESS_DELAY);
await click("button", { text: "Edit" });
await insertText(".o-mail-Message .o-mail-Composer-input", "edited message", { replace: true });
await click("button[title='Save editing']");
await contains(".o-mail-Message", { text: "edited message (edited)" });
});
test("Don't show chat hub in discuss app on mobile", async () => {
mockTouch(true);
mockUserAgent("android");
patchUiSize({ size: SIZES.SM });
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "test" });
setupChatHub({ folded: [channelId] });
await start();
await contains(".o-mail-ChatBubble");
await openDiscuss();
await contains(".o-mail-ChatBubble", { count: 0 });
});
test("click on an odoo link should fold the chat window (mobile)", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({});
patchUiSize({ size: SIZES.SM });
await start();
await openDiscuss(channelId);
await insertText(".o-mail-Composer-input", `http://${browser.location.host}/odoo.com`);
await click(".o-mail-Composer button[title='Send']");
await contains(".o-mail-ChatWindow");
await click(`a[href="http://${browser.location.host}/odoo.com"]`);
await contains(".o-mail-ChatWindow", { count: 0 });
await contains(".o-mail-ChatBubble", { count: 0 });
await openListView("discuss.channel", { res_id: channelId });
await contains(".o-mail-ChatBubble");
assertChatHub({ folded: [channelId] });
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,91 @@
declare module "mock_models" {
import { Base as Base2 } from "@mail/../tests/mock_server/mock_models/base";
import { DiscussChannel as DiscussChannel2 } from "@mail/../tests/mock_server/mock_models/discuss_channel";
import { DiscussChannelMember as DiscussChannelMember2 } from "@mail/../tests/mock_server/mock_models/discuss_channel_member";
import { DiscussChannelRtcSession as DiscussChannelRtcSession2 } from "@mail/../tests/mock_server/mock_models/discuss_channel_rtc_session";
import { DiscussVoiceMetadata as DiscussVoiceMetadata2 } from "@mail/../tests/mock_server/mock_models/discuss_voice_metadata";
import { IrAttachment as IrAttachment2 } from "@mail/../tests/mock_server/mock_models/ir_attachment";
import { MailActivity as MailActivity2 } from "@mail/../tests/mock_server/mock_models/mail_activity";
import { MailActivityType as MailActivityType2 } from "@mail/../tests/mock_server/mock_models/mail_activity_type";
import { MailFollowers as MailFollowers2 } from "@mail/../tests/mock_server/mock_models/mail_followers";
import { MailGuest as MailGuest2 } from "@mail/../tests/mock_server/mock_models/mail_guest";
import { MailLinkPreview as MailLinkPreview2 } from "@mail/../tests/mock_server/mock_models/mail_link_preview";
import { MailMessage as MailMessage2 } from "@mail/../tests/mock_server/mock_models/mail_message";
import { MailMessageLinkPreview as MailMessageLinkPreview2 } from "@mail/../tests/mock_server/mock_models/mail_message_link_preview";
import { MailMessageReaction as MailMessageReaction2 } from "@mail/../tests/mock_server/mock_models/mail_message_reaction";
import { MailMessageSubtype as MailMessageSubtype2 } from "@mail/../tests/mock_server/mock_models/mail_message_subtype";
import { MailNotification as MailNotification2 } from "@mail/../tests/mock_server/mock_models/mail_notification";
import { MailScheduledMessage as MailScheduledMessage2 } from "@mail/.../tests/mock_server/mock_models/mail_scheduled_message";
import { MailShortcode as MailShortcode2 } from "@mail/../tests/mock_server/mock_models/mail_shortcode";
import { MailTemplate as MailTemplate2 } from "@mail/../tests/mock_server/mock_models/mail_template";
import { MailThread as MailThread2 } from "@mail/../tests/mock_server/mock_models/mail_thread";
import { MailTrackingValue as MailTrackingValue2 } from "@mail/../tests/mock_server/mock_models/mail_tracking_value";
import { ResFake as ResFake2 } from "@mail/../tests/mock_server/mock_models/res_fake";
import { ResLang as ResLang2 } from "@mail/../tests/mock_server/mock_models/res_lang";
import { ResRole as ResRole2 } from "addons/mail/static/tests/mock_server/mock_models/res_role";
import { ResPartner as ResPartner2 } from "@mail/../tests/mock_server/mock_models/res_partner";
import { ResUsers as ResUsers2 } from "@mail/../tests/mock_server/mock_models/res_users";
import { ResUsersSettings as ResUsersSettings2 } from "@mail/../tests/mock_server/mock_models/res_users_settings";
import { ResUsersSettingsVolumes as ResUsersSettingsVolumes2 } from "@mail/../tests/mock_server/mock_models/res_users_settings_volumes";
export interface Base extends Base2 {}
export interface DiscussChannel extends DiscussChannel2 {}
export interface DiscussChannelMember extends DiscussChannelMember2 {}
export interface DiscussChannelRtcSession extends DiscussChannelRtcSession2 {}
export interface DiscussVoiceMetadata extends DiscussVoiceMetadata2 {}
export interface IrAttachment extends IrAttachment2 {}
export interface MailActivity extends MailActivity2 {}
export interface MailActivityType extends MailActivityType2 {}
export interface MailFollowers extends MailFollowers2 {}
export interface MailGuest extends MailGuest2 {}
export interface MailLinkPreview extends MailLinkPreview2 {}
export interface MailMessage extends MailMessage2 {}
export interface MailMessageLinkPreview extends MailMessageLinkPreview2 {}
export interface MailMessageReaction extends MailMessageReaction2 {}
export interface MailMessageSubtype extends MailMessageSubtype2 {}
export interface MailNotification extends MailNotification2 {}
export interface MailScheduledMessage extends MailScheduledMessage2 {}
export interface MailShortcode extends MailShortcode2 {}
export interface MailTemplate extends MailTemplate2 {}
export interface MailThread extends MailThread2 {}
export interface MailTrackingValue extends MailTrackingValue2 {}
export interface ResFake extends ResFake2 {}
export interface ResLang extends ResLang2 {}
export interface ResPartner extends ResPartner2 {}
export interface ResRole extends ResRole2 {}
export interface ResUsers extends ResUsers2 {}
export interface ResUsersSettings extends ResUsersSettings2 {}
export interface ResUsersSettingsVolumes extends ResUsersSettingsVolumes2 {}
export interface Models {
"base": Base,
"discuss.channel": DiscussChannel,
"discuss.channel.member": DiscussChannelMember,
"discuss.channel.rtc.session": DiscussChannelRtcSession,
"discuss.voice.metadata": DiscussVoiceMetadata,
"ir.attachment": IrAttachment,
"mail.activity": MailActivity,
"mail.activity.type": MailActivityType,
"mail.followers": MailFollowers,
"mail.guest": MailGuest,
"mail.link.preview": MailLinkPreview,
"mail.message": MailMessage,
"mail.message.link.preview": MailMessageLinkPreview,
"mail.message.reaction": MailMessageReaction,
"mail.message.subtype": MailMessageSubtype,
"mail.notification": MailNotification,
"mail.scheduled.message": MailScheduledMessage,
"mail.shortcode": MailShortcode,
"mail.template": MailTemplate,
"mail.thread": MailThread,
"mail.tracking.value": MailTrackingValue,
"res.fake": ResFake,
"res.groups": ResGroups,
"res.lang": ResLang,
"res.partner": ResPartner,
"res.role": ResRole,
"res.users": ResUsers,
"res.users.settings": ResUsersSettings,
"res.users.settings.volumes": ResUsersSettingsVolumes,
}
}

View file

@ -0,0 +1,66 @@
import { getKwArgs, models } from "@web/../tests/web_test_helpers";
import { patch } from "@web/core/utils/patch";
patch(models.ServerModel.prototype, {
/**
* @override
* @type {typeof models.ServerModel["prototype"]["get_views"]}
*/
get_views() {
const result = super.get_views(...arguments);
for (const modelName of Object.keys(result.models)) {
if (this.has_activities) {
result.models[modelName].has_activities = true;
}
}
return result;
},
});
export class Base extends models.ServerModel {
_name = "base";
/**
* @param {Object} trackedFieldNamesToField
* @param {Object} initialTrackedFieldValues
* @param {Object} record
*/
_mail_track(trackedFieldNamesToField, initialTrackedFieldValues, record) {
const kwargs = getKwArgs(
arguments,
"trackedFieldNamesToField",
"initialTrackedFieldValues",
"record"
);
trackedFieldNamesToField = kwargs.trackedFieldNamesToField;
initialTrackedFieldValues = kwargs.initialTrackedFieldValues;
record = kwargs.record;
/** @type {import("mock_models").MailTrackingValue} */
const MailTrackingValue = this.env["mail.tracking.value"];
const trackingValueIds = [];
const changedFieldNames = [];
for (const fname in trackedFieldNamesToField) {
const initialValue = initialTrackedFieldValues[fname];
const newValue = record[fname];
if (!initialValue && !newValue) {
continue;
}
if (initialValue !== newValue) {
const tracking = MailTrackingValue._create_tracking_values(
initialValue,
newValue,
fname,
trackedFieldNamesToField[fname],
this
);
if (tracking) {
trackingValueIds.push(tracking);
}
changedFieldNames.push(fname);
}
}
return { changedFieldNames, trackingValueIds };
}
}

Some files were not shown because too many files have changed in this diff Show more