合并企业版代码(未测试,先提交到测试分支)

This commit is contained in:
qihao.gong@jikimo.com
2023-04-14 17:42:23 +08:00
parent 7a7b3d7126
commit d28525526a
1300 changed files with 513579 additions and 5426 deletions

View File

@@ -0,0 +1,502 @@
/** @odoo-module **/
import { registerCleanup } from "@web/../tests/helpers/cleanup";
import {
click,
findChildren,
getFixture,
nextTick,
triggerEvent,
} from "@web/../tests/helpers/utils";
import { AppCreatorWrapper } from "@web_studio/client_action/app_creator/app_creator";
import { IconCreator } from "@web_studio/client_action/icon_creator/icon_creator";
import testUtils from "web.test_utils";
const { Component } = owl;
const sampleIconUrl = "/web_enterprise/Parent.src/img/default_icon_app.png";
const createAppCreator = async ({ env, rpc, state, onNewAppCreated }) => {
onNewAppCreated = onNewAppCreated || (() => {});
const cleanUp = await testUtils.mock.addMockEnvironmentOwl(Component, {
debug: QUnit.config.debug,
env,
mockRPC: rpc,
});
const target = getFixture();
const wrapper = new AppCreatorWrapper(null, { onNewAppCreated });
await wrapper.prependTo(target);
const { component } = findChildren(wrapper.appCreatorComponent);
if (state) {
Object.assign(component.state, state);
await nextTick();
}
registerCleanup(() => {
wrapper.destroy();
cleanUp();
});
return { state: component.state, target };
};
const editInput = async (el, selector, value) => {
const target = el.querySelector(selector);
target.value = value;
await triggerEvent(target, null, "input");
};
QUnit.module("Studio", (hooks) => {
hooks.beforeEach(() => {
IconCreator.enableTransitions = false;
registerCleanup(() => {
IconCreator.enableTransitions = true;
});
});
QUnit.module("AppCreator");
QUnit.test("app creator: standard flow with model creation", async (assert) => {
assert.expect(39);
const { state, target } = await createAppCreator({
env: {
services: {
ui: {
block: () => assert.step("UI blocked"),
unblock: () => assert.step("UI unblocked"),
},
async httpRequest(route) {
if (route === "/web/binary/upload_attachment") {
assert.step(route);
return `[{ "id": 666 }]`;
}
},
http: {
async post(route) {
if (route === "/web/binary/upload_attachment") {
assert.step(route);
return `[{ "id": 666 }]`;
}
},
},
},
},
onNewAppCreated: () => assert.step("new-app-created"),
async rpc(route, params) {
if (route === "/web_studio/create_new_app") {
const { app_name, menu_name, model_choice, model_id, model_options } = params;
assert.strictEqual(app_name, "Kikou", "App name should be correct");
assert.strictEqual(menu_name, "Petite Perruche", "Menu name should be correct");
assert.notOk(model_id, "Should not have a model id");
assert.strictEqual(model_choice, "new", "Model choice should be 'new'");
assert.deepEqual(
model_options,
["use_partner", "use_sequence", "use_mail", "use_active"],
"Model options should include the defaults and 'use_partner'"
);
}
if (route === "/web/dataset/call_kw/ir.attachment/read") {
assert.strictEqual(params.model, "ir.attachment");
return [{ datas: sampleIconUrl }];
}
},
});
// step: 'welcome'
assert.strictEqual(state.step, "welcome", "Current step should be welcome");
assert.containsNone(
target,
".o_web_studio_app_creator_previous",
"Previous button should not be rendered at step welcome"
);
assert.hasClass(
target.querySelector(".o_web_studio_app_creator_next"),
"is_ready",
"Next button should be ready at step welcome"
);
// go to step: 'app'
await click(target, ".o_web_studio_app_creator_next");
assert.strictEqual(state.step, "app", "Current step should be app");
assert.containsOnce(
target,
".o_web_studio_icon_creator .o_web_studio_selectors",
"Icon creator should be rendered in edit mode"
);
// Icon creator interactions
const icon = target.querySelector(".o_app_icon i");
// Initial state: take default values
assert.strictEqual(
target.querySelector(".o_app_icon").style.backgroundColor,
"rgb(52, 73, 94)",
"default background color: #34495e"
);
assert.strictEqual(icon.style.color, "rgb(241, 196, 15)", "default color: #f1c40f");
assert.hasClass(icon, "fa fa-diamond", "default icon class: diamond");
await click(target.getElementsByClassName("o_web_studio_selector")[0]);
assert.containsOnce(target, ".o_web_studio_palette", "the first palette should be open");
await triggerEvent(target, ".o_web_studio_palette", "mouseleave");
assert.containsNone(
target,
".o_web_studio_palette",
"leaving palette with mouse should close it"
);
await click(target.querySelectorAll(".o_web_studio_selectors > .o_web_studio_selector")[0]);
await click(target.querySelectorAll(".o_web_studio_selectors > .o_web_studio_selector")[1]);
assert.containsOnce(
target,
".o_web_studio_palette",
"opening another palette should close the first"
);
await click(target.querySelectorAll(".o_web_studio_palette div")[2]);
await click(target.querySelectorAll(".o_web_studio_selectors > .o_web_studio_selector")[2]);
await click(target.querySelectorAll(".o_web_studio_icons_library div")[43]);
await triggerEvent(target, ".o_web_studio_icons_library", "mouseleave");
assert.containsNone(
target,
".o_web_studio_palette",
"no palette should be visible anymore"
);
assert.strictEqual(
target.querySelectorAll(".o_web_studio_selector")[1].style.backgroundColor,
"rgb(0, 222, 201)", // translation of #00dec9
"color selector should have changed"
);
assert.strictEqual(
icon.style.color,
"rgb(0, 222, 201)",
"icon color should also have changed"
);
assert.hasClass(
target.querySelector(".o_web_studio_selector i"),
"fa fa-heart",
"class selector should have changed"
);
assert.hasClass(icon, "fa fa-heart", "icon class should also have changed");
// Click and upload on first link: upload a file
// mimic the event triggered by the upload (jquery)
// we do not use the triggerEvent helper as it requires the element to be visible,
// which isn't the case here (and this is valid)
target.querySelector(".o_web_studio_upload input").dispatchEvent(new Event("change"));
await nextTick();
assert.strictEqual(
state.iconData.uploaded_attachment_id,
666,
"attachment id should have been given by the RPC"
);
assert.strictEqual(
target.querySelector(".o_web_studio_uploaded_image").style.backgroundImage,
`url("data:image/png;base64,${sampleIconUrl}")`,
"icon should take the updated attachment data"
);
// try to go to step 'model'
await click(target, ".o_web_studio_app_creator_next");
const appNameInput = target.querySelector('input[name="appName"]').parentNode;
assert.strictEqual(
state.step,
"app",
"Current step should not be update because the input is not filled"
);
assert.hasClass(
appNameInput,
"o_web_studio_app_creator_field_warning",
"Input should be in warning mode"
);
await editInput(target, 'input[name="appName"]', "Kikou");
assert.doesNotHaveClass(
appNameInput,
"o_web_studio_app_creator_field_warning",
"Input shouldn't be in warning mode anymore"
);
// step: 'model'
await click(target, ".o_web_studio_app_creator_next");
assert.strictEqual(state.step, "model", "Current step should be model");
assert.containsNone(
target,
".o_web_studio_selectors",
"Icon creator should be rendered in readonly mode"
);
// try to go to next step
await click(target, ".o_web_studio_app_creator_next");
assert.hasClass(
target.querySelector('input[name="menuName"]').parentNode,
"o_web_studio_app_creator_field_warning",
"Input should be in warning mode"
);
await editInput(target, 'input[name="menuName"]', "Petite Perruche");
// go to next step (model configuration)
await click(target, ".o_web_studio_app_creator_next");
assert.strictEqual(
state.step,
"model_configuration",
"Current step should be model_configuration"
);
assert.containsOnce(
target,
'input[name="use_active"]',
"Debug options should be visible without debug mode"
);
// check an option
await click(target, 'input[name="use_partner"]');
assert.containsOnce(
target,
'input[name="use_partner"]:checked',
"Option should have been checked"
);
// go back then go forward again
await click(target, ".o_web_studio_model_configurator_previous");
await click(target, ".o_web_studio_app_creator_next");
// options should have been reset
assert.containsNone(
target,
'input[name="use_partner"]:checked',
"Options should have been reset by going back then forward"
);
// check the option again, we want to test it in the RPC
await click(target, 'input[name="use_partner"]');
await click(target, ".o_web_studio_model_configurator_next");
assert.verifySteps([
"/web/binary/upload_attachment",
"UI blocked",
"new-app-created",
"UI unblocked",
]);
});
QUnit.test("app creator: has 'lines' options to auto-create a one2many", async (assert) => {
assert.expect(7);
const { target } = await createAppCreator({
env: {
services: {
ui: { block: () => {}, unblock: () => {} },
},
},
rpc: async (route, params) => {
if (route === "/web_studio/create_new_app") {
const { app_name, menu_name, model_choice, model_id, model_options } = params;
assert.strictEqual(app_name, "testApp", "App name should be correct");
assert.strictEqual(menu_name, "testMenu", "Menu name should be correct");
assert.notOk(model_id, "Should not have a model id");
assert.strictEqual(model_choice, "new", "Model choice should be 'new'");
assert.deepEqual(
model_options,
["lines", "use_sequence", "use_mail", "use_active"],
"Model options should include the defaults and 'lines'"
);
}
},
});
await click(target, ".o_web_studio_app_creator_next");
await editInput(target, "input[id='appName']", "testApp");
await click(target, ".o_web_studio_app_creator_next");
await editInput(target, "input[id='menuName']", "testMenu");
await click(target, ".o_web_studio_app_creator_next");
assert.containsOnce(
target,
".o_web_studio_model_configurator_option input[type='checkbox'][name='lines'][id='lines']"
);
assert.strictEqual(
target.querySelector("label[for='lines']").textContent,
"LinesAdd details to your records with an embedded list view"
);
await click(
target,
".o_web_studio_model_configurator_option input[type='checkbox'][name='lines']"
);
await click(target, ".o_web_studio_model_configurator_next");
});
QUnit.test("app creator: debug flow with existing model", async (assert) => {
assert.expect(16);
const { state, target } = await createAppCreator({
env: {
isDebug: () => true,
services: {
ui: { block: () => {}, unblock: () => {} },
},
},
async rpc(route, params) {
assert.step(route);
switch (route) {
case "/web/dataset/call_kw/ir.model/name_search": {
assert.strictEqual(
params.model,
"ir.model",
"request should target the right model"
);
return [[69, "The Value"]];
}
case "/web_studio/create_new_app": {
assert.strictEqual(
params.model_id,
69,
"model id should be the one provided"
);
}
}
},
state: {
menuName: "Kikou",
step: "model",
},
});
let buttonNext = target.querySelector("button.o_web_studio_app_creator_next");
assert.hasClass(buttonNext, "is_ready");
await editInput(target, 'input[name="menuName"]', "Petite Perruche");
// check the 'new model' radio
await click(target, 'input[name="model_choice"][value="new"]');
// go to next step (model configuration)
await click(target, ".o_web_studio_app_creator_next");
assert.strictEqual(
state.step,
"model_configuration",
"Current step should be model_configuration"
);
assert.containsOnce(
target,
'input[name="use_active"]',
"Debug options should be visible in debug mode"
);
// go back, we want the 'existing model flow'
await click(target, ".o_web_studio_model_configurator_previous");
// since we came back, we need to update our buttonNext ref - the querySelector is not live
buttonNext = target.querySelector("button.o_web_studio_app_creator_next");
// check the 'existing model' radio
await click(target, 'input[name="model_choice"][value="existing"]');
assert.doesNotHaveClass(
target.querySelector(".o_web_studio_app_creator_model"),
"o_web_studio_app_creator_field_warning"
);
assert.doesNotHaveClass(buttonNext, "is_ready");
assert.containsOnce(
target,
".o_field_many2one",
"There should be a many2one to select a model"
);
await click(buttonNext);
assert.hasClass(
target.querySelector(".o_web_studio_app_creator_model"),
"o_web_studio_app_creator_field_warning"
);
assert.doesNotHaveClass(buttonNext, "is_ready");
await click(target, ".o_field_many2one input");
await click(document.querySelector(".ui-menu-item-wrapper"));
assert.strictEqual(target.querySelector(".o_field_many2one input").value, "The Value");
assert.doesNotHaveClass(
target.querySelector(".o_web_studio_app_creator_model"),
"o_web_studio_app_creator_field_warning"
);
assert.hasClass(buttonNext, "is_ready");
await click(buttonNext);
assert.verifySteps([
"/web/dataset/call_kw/ir.model/name_search",
"/web_studio/create_new_app",
]);
});
QUnit.test('app creator: navigate through steps using "ENTER"', async (assert) => {
assert.expect(12);
const { state, target } = await createAppCreator({
env: {
services: {
ui: {
block: () => assert.step("UI blocked"),
unblock: () => assert.step("UI unblocked"),
},
},
},
onNewAppCreated: () => assert.step("new-app-created"),
async rpc(route, { app_name, menu_name, model_id }) {
if (route === "/web_studio/create_new_app") {
assert.strictEqual(app_name, "Kikou", "App name should be correct");
assert.strictEqual(menu_name, "Petite Perruche", "Menu name should be correct");
assert.notOk(model_id, "Should not have a model id");
}
},
});
// step: 'welcome'
assert.strictEqual(state.step, "welcome", "Current step should be set to 1");
// go to step 'app'
await triggerEvent(document, null, "keydown", { key: "Enter" });
assert.strictEqual(state.step, "app", "Current step should be set to app");
// try to go to step 'model'
await triggerEvent(document, null, "keydown", { key: "Enter" });
assert.strictEqual(
state.step,
"app",
"Current step should not be update because the input is not filled"
);
await editInput(target, 'input[name="appName"]', "Kikou");
// go to step 'model'
await triggerEvent(document, null, "keydown", { key: "Enter" });
assert.strictEqual(state.step, "model", "Current step should be model");
// try to create app
await triggerEvent(document, null, "keydown", { key: "Enter" });
assert.hasClass(
target.querySelector('input[name="menuName"]').parentNode,
"o_web_studio_app_creator_field_warning",
"a warning should be displayed on the input"
);
await editInput(target, 'input[name="menuName"]', "Petite Perruche");
await triggerEvent(document, null, "keydown", { key: "Enter" });
await triggerEvent(document, null, "keydown", { key: "Enter" });
assert.verifySteps(["UI blocked", "new-app-created", "UI unblocked"]);
});
});

View File

@@ -0,0 +1,94 @@
/** @odoo-module */
import { legacyExtraNextTick, click } from "@web/../tests/helpers/utils";
import { registry } from "@web/core/registry";
import { systrayItem } from "@web_studio/systray_item/systray_item";
import { ormService } from "@web/core/orm_service";
import { enterpriseSubscriptionService } from "@web_enterprise/webclient/home_menu/enterprise_subscription_service";
import { homeMenuService } from "@web_enterprise/webclient/home_menu/home_menu_service";
import { studioService } from "@web_studio/studio_service";
import { registerCleanup } from "@web/../tests/helpers/cleanup";
import { resetViewCompilerCache } from "@web/views/view_compiler";
export function registerStudioDependencies() {
const serviceRegistry = registry.category("services");
registry.category("systray").add("StudioSystrayItem", systrayItem);
serviceRegistry.add("orm", ormService);
serviceRegistry.add("enterprise_subscription", enterpriseSubscriptionService);
serviceRegistry.add("home_menu", homeMenuService);
serviceRegistry.add("studio", studioService);
registerCleanup(() => resetViewCompilerCache());
}
export async function openStudio(target, params = {}) {
await click(target.querySelector(".o_main_navbar .o_web_studio_navbar_item a"));
await legacyExtraNextTick();
if (params.noEdit) {
const studioTabViews = target.querySelector(".o_web_studio_menu_item a");
await click(studioTabViews);
const controlElm = target.querySelector(
".o_action_manager .o_web_studio_editor .o_web_studio_views"
);
if (!controlElm) {
throw new Error("We should be in the Tab 'Views' but we are not");
}
}
if (params.report) {
const studioTabReport = target.querySelectorAll(".o_web_studio_menu_item a")[1];
await click(studioTabReport);
await legacyExtraNextTick();
let controlElm = target.querySelector(
".o_action_manager .o_web_studio_editor .o_web_studio_report_kanban"
);
if (!controlElm) {
throw new Error("We should be in the Tab 'Report' but we are not");
}
await click(controlElm.querySelector(`.o_kanban_record[data-id="${params.report}"`));
await legacyExtraNextTick();
controlElm = target.querySelector(
".o_action_manager .o_web_studio_editor .o_web_studio_report_editor_manager"
);
if (!controlElm) {
throw new Error("We should be editing the first report that showed up");
}
}
}
export async function leaveStudio(target) {
await click(target.querySelector(".o_studio_navbar .o_web_studio_leave a"));
return legacyExtraNextTick();
}
export function getReportServerData() {
const models = {
"ir.actions.report": {
fields: {
model: { type: "char" },
report_name: { type: "char" },
report_type: { type: "char" },
},
records: [{ id: 11, model: "foo", report_name: "foo_report", report_type: "pdf" }],
},
};
const views = {
"ir.actions.report,false,kanban": `
<kanban class="o_web_studio_report_kanban" js_class="studio_report_kanban">
<field name="report_name"/>
<field name="report_type"/>
<field name="id"/>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_global_click" t-att-data-id="record.id.value">
<div class="oe_kanban_details">
<field name="report_name" groups="base.group_no_one"/>
</div>
</div>
</t>
</templates>
</kanban>`,
"ir.actions.report,false,search": `<search />`,
};
return { models, views };
}

View File

@@ -0,0 +1,84 @@
/** @odoo-module **/
import { registerCleanup } from "@web/../tests/helpers/cleanup";
import { click, getFixture, mount } from "@web/../tests/helpers/utils";
import { IconCreator } from "@web_studio/client_action/icon_creator/icon_creator";
import makeTestEnvironment from "web.test_env";
const sampleIconUrl = "/web_enterprise/Parent.src/img/default_icon_app.png";
QUnit.module("Studio", (hooks) => {
hooks.beforeEach(() => {
IconCreator.enableTransitions = false;
registerCleanup(() => {
IconCreator.enableTransitions = true;
});
});
QUnit.module("IconCreator");
QUnit.test("icon creator: with initial web icon data", async (assert) => {
assert.expect(5);
const target = getFixture();
await mount(IconCreator, target, {
props: {
editable: true,
type: "base64",
webIconData: sampleIconUrl,
onIconChange(icon) {
// default values
assert.step("icon-changed");
assert.deepEqual(icon, {
backgroundColor: "#34495e",
color: "#f1c40f",
iconClass: "fa fa-diamond",
type: "custom_icon",
});
},
},
env: makeTestEnvironment(),
});
assert.strictEqual(
target.querySelector(".o_web_studio_uploaded_image").style.backgroundImage,
`url("${sampleIconUrl}")`,
"displayed image should prioritize web icon data"
);
// click on first link: "Design icon"
await click(target.querySelector(".o_web_studio_upload a"));
assert.verifySteps(["icon-changed"]);
assert.strictEqual(
target.querySelector(".o_web_studio_upload input").accept,
"image/png",
"Input should now only accept pngs"
);
});
QUnit.test("icon creator: without initial web icon data", async (assert) => {
assert.expect(3);
const target = getFixture();
await mount(IconCreator, target, {
props: {
backgroundColor: "rgb(255, 0, 128)",
color: "rgb(0, 255, 0)",
editable: false,
iconClass: "fa fa-heart",
type: "custom_icon",
onIconChange: () => {},
},
env: makeTestEnvironment(),
});
// Attributes should be correctly set
assert.strictEqual(
target.querySelector(".o_app_icon").style.backgroundColor,
"rgb(255, 0, 128)"
);
assert.strictEqual(target.querySelector(".o_app_icon i").style.color, "rgb(0, 255, 0)");
assert.hasClass(target.querySelector(".o_app_icon i"), "fa fa-heart");
});
});

View File

@@ -0,0 +1,186 @@
odoo.define('web_studio.ActionEditorActionTests', function (require) {
"use strict";
var testUtils = require('web.test_utils');
const { openStudio, registerStudioDependencies } = require("@web_studio/../tests/helpers");
const { doAction } = require("@web/../tests/webclient/helpers");
const { getFixture, legacyExtraNextTick } = require("@web/../tests/helpers/utils");
const { createEnterpriseWebClient } = require("@web_enterprise/../tests/helpers");
let serverData;
let target;
QUnit.module('Studio', {
beforeEach: function () {
this.data = {
kikou: {
fields: {
display_name: { type: "char", string: "Display Name" },
start: { type: 'datetime', store: 'true', string: "start date" },
},
},
'res.groups': {
fields: {
display_name: { string: "Display Name", type: "char" },
},
records: [{
id: 4,
display_name: "Admin",
}],
},
};
const views = {
"kikou,1,list": `<tree><field name="display_name" /></tree>`,
"kikou,2,form": `<form><field name="display_name" /></form>`,
"kikou,false,search": `<search />`,
};
serverData = {models: this.data, views};
target = getFixture();
registerStudioDependencies();
}
}, function () {
QUnit.module('ActionEditorAction');
QUnit.test('add a gantt view', async function (assert) {
assert.expect(5);
const mockRPC = (route, args) => {
if (route === '/web_studio/add_view_type') {
assert.strictEqual(args.view_type, 'gantt',
"should add the correct view");
return Promise.resolve(false);
} else if (args.method === 'fields_get') {
assert.strictEqual(args.model, 'kikou',
"should read fields on the correct model");
}
};
const webClient = await createEnterpriseWebClient({ serverData, mockRPC });
await doAction(webClient, {
xml_id: "some.xml_id",
type: "ir.actions.act_window",
res_model: 'kikou',
view_mode: 'list',
views: [[1, 'list'], [2, 'form']],
}, {clearBreadcrumbs: true});
await openStudio(target, {noEdit: true});
await testUtils.dom.click($(target).find('.o_web_studio_view_type[data-type="gantt"] .o_web_studio_thumbnail'));
await legacyExtraNextTick();
assert.containsOnce($, '.o_web_studio_new_view_dialog',
"there should be an opened dialog to select gantt attributes");
assert.strictEqual($('.o_web_studio_new_view_dialog select[name="date_start"]').val(), 'start',
"date start should be prefilled (mandatory)");
assert.strictEqual($('.o_web_studio_new_view_dialog select[name="date_stop"]').val(), 'start',
"date stop should be prefilled (mandatory)");
});
QUnit.test('disable the view from studio', async function (assert) {
assert.expect(3);
const actions = {
1: {
id: 1,
xml_id: "kikou.action",
name: 'Kikou Action',
res_model: 'kikou',
type: 'ir.actions.act_window',
view_mode: 'list,form',
views: [[1, 'list'], [2, 'form']],
}
};
const views = {
'kikou,1,list': `<tree><field name="display_name"/></tree>`,
'kikou,1,search': `<search></search>`,
'kikou,2,form': `<form><field name="display_name"/></form>`,
};
Object.assign(serverData, {actions, views});
let loadActionStep = 0;
const mockRPC = (route, args) => {
if (route === '/web_studio/edit_action') {
return true;
} else if (route === '/web/action/load') {
loadActionStep++;
/**
* step 1: initial action/load
* step 2: on disabling list view
*/
if (loadActionStep === 2) {
return {
name: 'Kikou Action',
res_model: 'kikou',
view_mode: 'form',
type: 'ir.actions.act_window',
views: [[2, 'form']],
id: 1,
};
}
}
};
const webClient = await createEnterpriseWebClient({ serverData, mockRPC });
await doAction(webClient, 1);
await openStudio(target);
await testUtils.dom.click(target.querySelector('.o_web_studio_menu_item a'));
// make list view disable and form view only will be there in studio view
await testUtils.dom.click($(target).find('div[data-type="list"] .o_web_studio_more'));
await testUtils.dom.click($(target).find('div[data-type="list"] a[data-action="disable_view"]'));
// reloadAction = false;
assert.hasClass(
$(target).find('div[data-type="list"]'),
'o_web_studio_inactive',
"list view should have become inactive");
// make form view disable and it should prompt the alert dialog
await testUtils.dom.click($(target).find('div[data-type="form"] .o_web_studio_more'));
await testUtils.dom.click($(target).find('div[data-type="form"] a[data-action="disable_view"]'));
assert.containsOnce(
$,
'.o_technical_modal',
"should display a modal when attempting to disable last view");
assert.strictEqual(
$('.o_technical_modal .modal-body').text().trim(),
"You cannot deactivate this view as it is the last one active.",
"modal should tell that last view cannot be disabled");
});
QUnit.test('add groups on action', async function (assert) {
assert.expect(1);
const actions = {
1: {
id: 1,
xml_id: "some.xml_id",
type: "ir.actions.act_window",
res_model: 'kikou',
view_mode: 'list',
views: [[1, 'list'], [2, 'form']],
},
};
Object.assign(serverData, {actions});
const mockRPC = (route, args) => {
if (route === '/web_studio/edit_action') {
assert.strictEqual(args.args.groups_id[0], 4,
"group admin should be applied on action");
return Promise.resolve(true);
}
};
const webClient = await createEnterpriseWebClient({ serverData, mockRPC });
await doAction(webClient, 1, {clearBreadcrumbs: true});
await openStudio(target, {noEdit: true});
await testUtils.fields.many2one.clickOpenDropdown('groups_id');
await testUtils.fields.many2one.clickHighlightedItem('groups_id');
});
});
});

View File

@@ -0,0 +1,204 @@
odoo.define('web_studio.EditMenu_tests', function (require) {
"use strict";
var testUtils = require('web.test_utils');
const { prepareWowlFormViewDialogs } = require("@web/../tests/views/helpers");
var EditMenu = require('web_studio.EditMenu');
QUnit.module('Studio', {
beforeEach: function () {
this.data = {
'ir.ui.menu': {
fields: {},
records: [{
id: 1,
name: 'Menu 1',
}, {
id: 2,
name: 'Menu 2',
}, {
id: 21,
name: 'Submenu 1',
}, {
id: 22,
name: 'Submenu 2',
}]
}
};
this.menu_data = {
childrenTree: [
{
id: 1,
name: 'Menu 1',
parent_id: false,
childrenTree: [],
}, {
id: 2,
name: 'Menu 2',
parent_id: false,
childrenTree: [
{
childrenTree: [],
id: 21,
name: 'Submenu 1',
parent_id: 2,
}, {
childrenTree: [],
id: 21,
name: 'Submenu 2',
parent_id: 2,
},
],
},
],
};
this.archs = {
'ir.ui.menu,false,form':
'<form>'+
'<sheet>' +
'<field name="name"/>' +
'</sheet>' +
'</form>'
};
}
}, function () {
QUnit.module('EditMenu');
QUnit.test('edit menu behavior', async function(assert) {
assert.expect(3);
var $target = $('#qunit-fixture');
var edit_menu = new EditMenu.MenuItem(null, this.menu_data, 2);
await edit_menu.appendTo($target);
await testUtils.mock.addMockEnvironment(edit_menu, {
data: this.data,
archs: this.archs,
});
assert.strictEqual($('.o_web_studio_edit_menu_modal').length, 0,
"there should not be any modal in the dom");
assert.containsOnce(edit_menu, '.o_web_edit_menu',
"there should be an edit menu link");
// open the dialog to edit the menu
await testUtils.dom.click(edit_menu.$('.o_web_edit_menu'));
assert.strictEqual($('.o_web_studio_edit_menu_modal').length, 1,
"there should be a modal in the dom");
edit_menu.destroy();
});
QUnit.test('edit menu dialog', async function(assert) {
assert.expect(23);
const dialog = new EditMenu.Dialog(null, this.menu_data, 2);
dialog.open();
await dialog.opened();
let customizeCalls = 0;
await testUtils.mock.addMockEnvironment(dialog, {
data: this.data,
archs: this.archs,
mockRPC: function (route, args) {
if (route === "/web/dataset/call_kw/ir.ui.menu/customize") {
if (customizeCalls === 0) {
assert.deepEqual(args.kwargs, {
to_delete: [],
to_move: {
2: { sequence: 1 },
21: { parent_menu_id: 2, sequence: 0 },
},
});
}
customizeCalls++;
return Promise.reject();
}
return this._super(route, args);
},
});
await prepareWowlFormViewDialogs({ models: this.data, views: this.archs });
const $modal = $('.modal');
assert.containsOnce(dialog, 'ul.oe_menu_editor',
"there should be the list of menus");
assert.containsOnce(dialog, 'ul.oe_menu_editor > li',
"there should be only one main menu");
assert.strictEqual(dialog.$('ul.oe_menu_editor > li').data('menu-id'), 2,
"the main menu should have the menu-id 2");
assert.containsOnce(dialog, 'ul.oe_menu_editor > li > div button.js_edit_menu',
"there should be a button to edit the menu");
assert.containsOnce(dialog, 'ul.oe_menu_editor > li > div button.js_delete_menu',
"there should be a button to remove the menu");
assert.containsN(dialog, 'ul.oe_menu_editor > li > ul > li', 2,
"there should be two submenus");
assert.containsOnce($modal, '.js_add_menu',
"there should be a link to add new menu");
// open the dialog to create a new menu
await testUtils.dom.click($modal.find('.js_add_menu'));
await testUtils.nextTick();
assert.strictEqual($('.o_web_studio_add_menu_modal').length, 1,
"there should be a modal in the dom");
assert.strictEqual($('.o_web_studio_add_menu_modal input[name="name"]').length, 1,
"there should be an input for the name in the dialog");
await testUtils.dom.click($('.o_web_studio_add_menu_modal .o_field_widget[name="model_choice"] [data-value="new"]'));
assert.isNotVisible($('.o_web_studio_add_menu_modal .o_field_many2one'),
"there should be no visible many2one for the model in the dialog");
// Define a name for new model
$('input[name="name"]').val("new_model");
await testUtils.dom.click($('.o_web_studio_add_menu_modal .btn-primary'));
assert.containsOnce($, '.o_web_studio_model_configurator input[name="use_partner"]',
"the ModelConfigurator should show the available model options");
await testUtils.dom.click($('.o_web_studio_model_configurator .o_web_studio_model_configurator_previous'));
assert.containsNone($, '.o_web_studio_model_configurator',
"the ModelConfigurator should be gone");
await testUtils.dom.click($('.o_web_studio_add_menu_modal .o_field_widget[name="model_choice"] [data-value="existing"]'));
assert.strictEqual($('.o_web_studio_add_menu_modal .o_field_many2one').filter(':visible').length, 1,
"there should be a visible many2one for the model in the dialog");
// add menu and close the modal
await testUtils.fields.editInput($('.o_web_studio_add_menu_modal input[name="name"]'), "AA");
await testUtils.dom.click($('.o_web_studio_add_menu_modal .btn-primary'));
await testUtils.dom.click($('.o_web_studio_add_menu_modal .btn-secondary'));
// move submenu above root menu
await testUtils.dom.dragAndDrop(dialog.$('li li .input-group:first'), dialog.$('.input-group:first'));
assert.strictEqual(dialog.to_move[2].sequence, dialog.to_move[21].sequence + 1,
"Root menu is after moved submenu");
// open the dialog to edit the menu
await testUtils.dom.click(dialog.$('.js_edit_menu:nth(1)'));
assert.strictEqual($('.o_dialog .o_form_view').length, 1,
"there should be a form view dialog in the dom");
assert.strictEqual($('.o_dialog .o_form_view .o_field_widget[name="name"] input').val(), "Menu 2",
"the edited menu should be menu 2");
// confirm the edition
assert.strictEqual(customizeCalls, 0, "current changes have not been saved");
await testUtils.dom.click($('.o_dialog .o_form_button_save'));
assert.strictEqual(customizeCalls, 1, "current changes are saved after editing a menu");
// delete the last menu
await testUtils.dom.click(dialog.$('.js_delete_menu:nth(2)'));
assert.containsNone(dialog, 'ul.oe_menu_editor > li > ul > li',
"there should be no submenu after deletion");
assert.strictEqual(dialog.to_delete.length, 1,
"there should be one menu to delete");
await testUtils.dom.click(dialog.$('.js_delete_menu:first'));
await testUtils.dom.click(dialog.$('.js_delete_menu:first'));
await testUtils.dom.click($modal.find('footer .btn-primary'));
assert.containsN($(document), '.modal', 2,
"should have 2 dialogs");
assert.strictEqual($('.modal:not(.o_inactive_modal) .modal-title').text(), "Alert",
"should have alert dialog if we delete all menus");
dialog.destroy();
});
});
});

View File

@@ -0,0 +1,51 @@
/** @odoo-module */
import MockServer from 'web.MockServer';
MockServer.include({
init() {
this._super(...arguments);
MockServer.currentMockServer = this;
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @override
* @private
* @returns {Promise}
*/
_performRpc: function (route) {
if (route === '/web_studio/get_default_value') {
return Promise.resolve({});
}
if (route === '/web_studio/activity_allowed') {
return Promise.resolve(false);
}
return this._super.apply(this, arguments);
},
/**
* Mocks method "_return_view" that generates the return value of a call
* to edit_view. It's basically an object similar to the result of a call
* to get_views. It is used in mockRPC functions that mock edit_view calls.
*
* @param {string} arch
* @param {string} model
* @private
* @returns {Object}
*/
_mockReturnView(arch, model) {
const view = this.getView({ arch, model });
const models = {};
for (const modelName of view.models) {
models[modelName] = this.fieldsGet(modelName);
}
return Promise.resolve({
models,
views: { [view.type]: view },
});
},
});

View File

@@ -0,0 +1,54 @@
odoo.define('web_studio.NewModeltests', function (require) {
"use strict";
const NewModel = require('web_studio.NewModel');
const testUtils = require('web.test_utils');
QUnit.module('Studio', function () {
QUnit.module('NewModel');
QUnit.test('Add New Model', async function (assert) {
assert.expect(7);
const $target = $('#qunit-fixture');
const newModel = new NewModel.NewModelItem();
await newModel.appendTo($target);
testUtils.mock.addMockEnvironment(newModel, {
mockRPC: function (route, args) {
if (route === "/web_studio/create_new_menu") {
assert.strictEqual(args.menu_name, "ABCD", "Model name should be ABCD.")
return Promise.resolve();
}
return this._super(route, args);
},
});
assert.containsNone($, '.o_web_studio_new_model_modal',
"there should not be any modal in the dom");
assert.containsOnce(newModel, '.o_web_create_new_model',
"there should be an add new model link");
await testUtils.dom.click($('.o_web_create_new_model'));
assert.containsOnce($, '.o_web_studio_new_model_modal',
"there should be a modal in the dom");
const $modal = $('.modal');
assert.containsOnce($modal, 'input[name="name"]',
"there should be an input for the name in the dialog");
await testUtils.fields.editInput($modal.find('input[name="name"]'), "ABCD");
await testUtils.dom.click($modal.find('.btn-primary'));
const $configuratorModal = $('.o_web_studio_model_configurator');
assert.containsOnce($configuratorModal, 'input[name="use_partner"]',
"the ModelConfigurator should show the available model options");
await testUtils.dom.click($configuratorModal.find('.o_web_studio_model_configurator_next'));
assert.containsNone($, '.o_web_studio_model_configurator',
"the ModelConfigurator should be gone");
newModel.destroy();
});
});
});

View File

@@ -0,0 +1,87 @@
odoo.define('web_studio.ReportEditorAction_tests', function (require) {
"use strict";
const { controlPanel } = require('web.test_utils');
const { getPagerValue, pagerNext } = controlPanel;
const { getFixture } = require("@web/../tests/helpers/utils");
const { doAction } = require("@web/../tests/webclient/helpers");
const { openStudio, registerStudioDependencies,getReportServerData } = require("@web_studio/../tests/helpers");
const { createEnterpriseWebClient } = require("@web_enterprise/../tests/helpers");
let serverData;
let target;
QUnit.module('Studio', {
beforeEach: function () {
this.data = {
foo: {
fields: {},
records: [{ id: 22 }, { id: 23 }],
},
"ir.actions.report": {
fields: { model: { type: "char" }, report_name: { type: "char" }, report_type:{ type: "char" }},
records: [{ id: 11, model: "foo", report_name: "foo_report", report_type: "pdf" }],
},
"ir.model": {
fields: {},
},
};
const reportServerData = getReportServerData();
const actions = {
1: {
id: 1,
xml_id: "kikou.action",
name: 'Kikou Action',
res_model: 'foo',
type: 'ir.actions.act_window',
view_mode: 'list,form',
views: [[1, 'form']],
}
};
const views = Object.assign({
"foo,2,form": `<form><field name="display_name" /></form>`,
"foo,false,search": `<search />`,
}, reportServerData.views);
serverData = {actions, models: this.data, views};
Object.assign(serverData.models, reportServerData.models);
registerStudioDependencies();
target = getFixture();
},
}, function () {
QUnit.module('ReportEditorAction');
QUnit.test('use pager', async function (assert) {
assert.expect(2);
const reportHTML = `
<html>
<head/>
<body>
<div id="wrapwrap">
<main>
<div class="page"/>
</main>
</div>
</body>
</html>`;
const mockRPC = (route, args) => {
switch (route) {
case "/web_studio/get_report_views":
return { report_html: reportHTML };
case "/web_studio/get_widgets_available_options":
case "/web_studio/read_paperformat":
return {};
}
};
const webClient = await createEnterpriseWebClient({ serverData, mockRPC });
await doAction(webClient, 1);
await openStudio(target, {report: 11});
assert.strictEqual(getPagerValue(target), "1");
await pagerNext(target);
assert.strictEqual(getPagerValue(target), "2");
});
});
});

View File

@@ -0,0 +1,800 @@
odoo.define('web_studio.ReportEditorComponents_tests', function (require) {
"use strict";
var testUtils = require('web.test_utils');
var studioTestUtils = require('web_studio.testUtils');
var Widget = require('web.Widget');
var studioTestUtils = require('web_studio.testUtils');
var editComponentsRegistry = require('web_studio.reportEditComponentsRegistry');
var reportNewComponentsRegistry = require('web_studio.reportNewComponentsRegistry');
QUnit.module('Studio', {}, function () {
QUnit.module('ReportComponents', {
before: function() {
return new Promise(function (resolve, reject) {
studioTestUtils.createSidebar({}).then(function (sidebar) {
sidebar.destroy();
resolve();
});
});
},
beforeEach: function () {
this.widgetsOptions = {
monetary: {
company_id: {
type: "model",
string: "Company",
description: "Company used for the original currency (only used for t-esc)",
default_value: "Company used to render the template",
params: "res.company"
},
date: {
type: "date",
string: "Date",
description: "Date used for the original currency (only used for t-esc)",
default_value: "Current date"
},
from_currency: {
type: "model",
string: "Original currency",
params: "res.currency"
},
display_currency: {
type: "model",
string: "Display currency",
required: "value_to_html",
params: "res.currency"
}
},
relative: {
now: {
type: "datetime",
string: "Reference date",
description: "Date to compare with the field value.",
default_value: "Current date"
}
},
image: {},
text: {},
html: {},
many2many: {},
date: {
format: {
type: "string",
string: "Date format"
}
},
datetime: {
time_only: {
type: "boolean",
string: "Display only the time"
},
hide_seconds: {
type: "boolean",
string: "Hide seconds"
},
format: {
type: "string",
string: "Pattern to format"
}
},
qweb: {},
many2one: {},
integer: {},
float_time: {},
contact: {
separator: {
type: "selection",
params : {
type: "selection",
selection: [
[" ", "Space"],
[",", "Comma"],
["-", "Dash"],
["|", "Vertical bar"],
["/", "Slash"]
],
placeholder: 'Linebreak',
},
string: "Address separator",
description: "Separator use to split the addresse from the display_name.",
default_value: false,
},
no_marker: {
type: "boolean",
string: "Hide marker",
description: "Don't display the font awsome marker"
},
country_image: {
type: "boolean",
string: "Displayed contry image",
description: "Display the country image if the field is present on the record"
},
fields: {
type: "array",
string: "Displayed fields",
description: "List of contact fields to display in the widget",
default_value: [
"name",
"address",
"phone",
"mobile",
"email"
],
params: {
type: "selection",
params: [
{'field_name': 'name', 'label': 'Name'},
{'field_name': 'address', 'label': 'Address'},
{'field_name': 'phone', 'label': 'Phone'},
{'field_name': 'mobile', 'label': 'Mobile'},
{'field_name': 'email', 'label': 'Email'},
{'field_name': 'vat', 'label': 'VAT'},
]
}
},
no_tag_br: {
type: "boolean",
string: "Use comma",
description: "Use comma instead of the <br> tag to display the address"
},
phone_icons: {
type: "boolean",
string: "Displayed phone icons",
description: "Display the phone icons even if no_marker is True"
}
},
duration: {
unit: {
type: "select",
string: "Date unit",
description: "Date unit used for comparison and formatting",
default_value: "hour",
params: [
[ "year", "year" ],
[ "month", "month" ],
[ "week", "week" ],
[ "day", "day" ],
[ "hour", "hour" ],
[ "minute", "minute" ],
[ "second", "second" ]
]
},
round: {
type: "select",
string: "Rounding unit",
description: "Date unit used for the rounding. If the value is given, this must be smaller than the unit",
default_value: "Same unit as \"unit\" option",
params: [
[ "year", "year" ],
[ "month", "month" ],
[ "week", "week" ],
[ "day", "day" ],
[ "hour", "hour" ],
[ "minute", "minute" ],
[ "second", "second" ]
]
}
},
selection: {
selection: {
type: "selection",
string: "Selection",
default_value: "Use the field information",
required: true
}
},
barcode: {
type: {
type: "string",
string: "Barcode type",
description: "Barcode type, eg: UPCA, EAN13, Code128",
default_value: "Code128"
},
width: {
type: "integer",
string: "Width",
default_value: 600
},
height: {
type: "integer",
string: "Height",
default_value: 100
},
humanreadable: {
type: "integer",
string: "Human Readable",
default_value: 0
}
},
float: {
precision: {
type: "integer",
string: "Rounding precision"
}
}
};
this.data = studioTestUtils.getData({
'model.test': {
fields: {
name: {string: "Name", type: "char"},
image: {string: "Image", type: "binary"},
child: {string: "Child", type: 'many2one', relation: 'model.test.child', searchable: true},
child_bis: {string: "Child Bis", type: 'many2one', relation: 'model.test.child', searchable: true},
children: {string: "Children", type: 'many2many', relation: 'model.test.child', searchable: true},
},
records: [],
},
'model.test.child': {
fields: {
name: { string: "Name", type: "char" },
company_id: { string: "Company", type: "many2one", relation: 'res.company', searchable: true },
currency_id: { string: "Currency", type: "many2one", relation: 'res.currency', searchable: true },
date: { string: "Date", type: "datetime", searchable: true },
},
records: [],
},
'res.company': {
fields: {
name: { string: "Name", type: "char" },
},
records: [],
},
'res.currency': {
fields: {
name: { string: "Name", type: "char" },
},
records: [],
},
});
studioTestUtils.patch();
},
afterEach: function () {
studioTestUtils.unpatch();
},
}, function () {
QUnit.module('New');
QUnit.test('field', async function (assert) {
assert.expect(2);
var parent = new Widget();
await testUtils.mock.addMockEnvironment(parent, {
data: this.data,
});
await parent.appendTo($('#qunit-fixture'));
var InlineField = reportNewComponentsRegistry.get('Inline')[1];
var tOptions = new InlineField(parent, {
models: {
'model.test': 'Toto',
},
});
tOptions.add({
targets: [{
data: {},
node: {
attrs: {
'data-oe-id': 99,
'data-oe-xpath': '/my/node/path/',
},
contextOrder: ['toto'],
context: {
toto: 'model.test',
},
parent: {
children: [],
attrs: {},
}
},
}]
}).then(function (res) {
assert.deepEqual(res.inheritance,
[{content: '<span t-field="toto.child"></span>', xpath: '/my/node/path/', view_id: 99, position: undefined}],
"Should send the operation");
});
await testUtils.nextTick();
await testUtils.dom.triggerEvents($('.o_web_studio_field_modal .o_field_selector'), ['focus']);
await testUtils.dom.click($('.o_web_studio_field_modal .o_field_selector_close'));
await testUtils.dom.click($('.o_web_studio_field_modal .btn-primary'));
assert.strictEqual($('.modal main[role="alert"]').length, 1,
"Should display an alert because the field name of the record is wrong");
await testUtils.dom.click($('.modal:has(main[role="alert"]) .btn-primary'));
await testUtils.dom.triggerEvents($('.o_web_studio_field_modal .o_field_selector'), ['focus']);
await testUtils.dom.click($('.o_web_studio_field_modal .o_field_selector_item[data-name="child"]'));
await testUtils.dom.click($('.o_web_studio_field_modal .btn-primary'));
parent.destroy();
});
QUnit.test('add a binary field', async function (assert) {
assert.expect(1);
var parent = new Widget();
await testUtils.mock.addMockEnvironment(parent, {
data: this.data,
});
await parent.appendTo($('#qunit-fixture'));
var InlineField = reportNewComponentsRegistry.get('Inline')[1];
var tOptions = new InlineField(parent, {
models: {
'model.test': 'Kikou',
},
});
tOptions.add({
targets: [{
data: {},
node: {
attrs: {
'data-oe-id': 99,
'data-oe-xpath': '/my/node/path/',
},
contextOrder: ['toto'],
context: {
toto: 'model.test',
},
parent: {
children: [],
attrs: {},
}
},
}]
}).then(function (res) {
assert.deepEqual(res.inheritance,
[{content: '<span t-field="toto.image" t-options-widget="&quot;image&quot;"></span>', xpath: '/my/node/path/', view_id: 99, position: undefined}],
"image widget should be set");
});
await testUtils.nextTick();
await testUtils.dom.triggerEvents($('.o_web_studio_field_modal .o_field_selector'), ['focus']);
await testUtils.dom.click($('.o_web_studio_field_modal .o_field_selector_item[data-name="image"]'));
await testUtils.dom.click($('.o_web_studio_field_modal .btn-primary'));
parent.destroy();
});
QUnit.module('Edit');
QUnit.test('column component with valid classes', async function (assert) {
assert.expect(2);
var parent = new Widget();
await parent.appendTo($('#qunit-fixture'));
var column = new (editComponentsRegistry.get('column'))(parent, {
node: {
attrs: {
class: 'col-5 offset-3',
},
},
});
await column.appendTo(parent.$el);
assert.strictEqual(column.$('input[name="size"]').val(), "5",
"the size should be correctly set");
assert.strictEqual(column.$('input[name="offset"]').val(), "3",
"the offset should be correctly set");
parent.destroy();
});
QUnit.test('column component with invalid classes', async function (assert) {
assert.expect(2);
var parent = new Widget();
await parent.appendTo($('#qunit-fixture'));
var column = new (editComponentsRegistry.get('column'))(parent, {
node: {
attrs: {
class: 'col- offset-kikou',
},
},
});
await column.appendTo(parent.$el);
assert.strictEqual(column.$('input[name="size"]').val(), "",
"the size should be unkown");
assert.strictEqual(column.$('input[name="offset"]').val(), "",
"the offset should be unkown");
parent.destroy();
});
QUnit.test('hidden "width" for layout component with col nodes', async function (assert) {
assert.expect(1);
var parent = new Widget();
await testUtils.mock.addMockEnvironment(parent, this);
await parent.appendTo($('#qunit-fixture'));
var layout = new (editComponentsRegistry.get('layout'))(parent, {
node: {
tag: 'div',
attrs: {
class: 'col- offset-kikou',
},
$nodes: $(),
},
});
await layout.appendTo(parent.$el);
assert.containsNone(layout.$('.o_web_studio_width'),
"the width attribute shouldn't be displayed for div.col nodes");
parent.destroy();
});
QUnit.test('tOptions component', async function (assert) {
assert.expect(3);
var parent = new Widget();
await parent.appendTo($('#qunit-fixture'));
var tOptions = new (editComponentsRegistry.get('tOptions'))(parent, {
widgetsOptions: this.widgetsOptions,
node: {
attrs: {
't-options': '{"widget": "text"}',
't-options-widget': '"image"',
't-options-other-options': 'True',
'data-oe-id': 99,
'data-oe-xpath': '/my/node/path/',
},
},
context: {},
state: null,
models: null,
});
await tOptions.appendTo(parent.$el);
assert.strictEqual(tOptions.$('select').val(), 'image',
"Should select the image widget");
assert.containsNone(tOptions, '.o_web_studio_toption_option',
"there should be no available option");
// unset the `widget`
await testUtils.mock.addMockEnvironment(parent, {
intercepts: {
view_change: function (ev) {
assert.deepEqual(ev.data.operation.new_attrs, {'t-options-widget': '""'},
"should correctly delete the group");
},
},
});
tOptions.$('select').val('').trigger('change');
parent.destroy();
});
QUnit.test('tOptions component parse expression', async function (assert) {
assert.expect(5);
var parent = new Widget();
await parent.appendTo($('#qunit-fixture'));
var fields = this.data['model.test.child'].fields;
fields.company_id = {string: "Company", type: "many2one", relation: 'res.company', searchable: true};
fields.currency_id = {string: "Currency", type: "many2one", relation: 'res.currency', searchable: true};
fields.date = {string: "Date", type: "datetime", searchable: true};
await testUtils.mock.addMockEnvironment(parent, {
data: this.data,
});
var tOptions = new (editComponentsRegistry.get('tOptions'))(parent, {
widgetsOptions: this.widgetsOptions,
node: {
attrs: {
't-options': 'dict(from_currency=o.child.currency_id, date=o.child.date)',
't-options-widget': '"monetary"',
't-options-company_id': 'o.child.company_id',
'data-oe-id': 99,
'data-oe-xpath': '/my/node/path/',
},
context: {"o": "model.test"},
},
context: {},
state: null,
models: {"model.test": "Model Test"},
});
await tOptions.appendTo(parent.$el);
assert.strictEqual(tOptions.$('select').val(), 'monetary',
"Should select the image widget");
assert.containsN(tOptions, '.o_web_studio_toption_option', 4,
"there should be 4 available options for the monetary widget");
assert.strictEqual(tOptions.$('.o_web_studio_toption_option_monetary_from_currency .o_field_selector_value').text().replace(/\s+/g, ''),
"o(ModelTest)ChildCurrency",
"Should display the currency field");
assert.strictEqual(tOptions.$('.o_web_studio_toption_option_monetary_date .o_field_selector_value').text().replace(/\s+/g, ''),
"o(ModelTest)ChildDate",
"Should display the data field");
assert.strictEqual(tOptions.$('.o_web_studio_toption_option_monetary_company_id .o_field_selector_value').text().replace(/\s+/g, ''),
"o(ModelTest)ChildCompany",
"Should display the company field");
parent.destroy();
});
QUnit.test('tEsc component with parsable expression', async function (assert) {
assert.expect(1);
var parent = new Widget();
await parent.appendTo($('#qunit-fixture'));
await testUtils.mock.addMockEnvironment(parent, {
data: this.data,
});
var tOptions = new (editComponentsRegistry.get('tEsc'))(parent, {
node: {
attrs: {
't-esc': 'o.child.company_id',
'data-oe-id': 99,
'data-oe-xpath': '/my/node/path/',
},
context: {"o": "model.test"},
},
context: {},
state: null,
models: {"model.test": "Model Test"},
});
await tOptions.appendTo(parent.$el);
await testUtils.nextTick();
// the component value is parsable so we display it with ModelFieldSelector
assert.strictEqual(tOptions.$('.o_field_selector_value').text().replace(/\s+/g, ''),
"o(ModelTest)ChildCompany",
"Should display the company field");
parent.destroy();
});
QUnit.test('tEsc component with non-parsable expression', async function (assert) {
assert.expect(1);
var parent = new Widget();
await parent.appendTo($('#qunit-fixture'));
await testUtils.mock.addMockEnvironment(parent, {
data: this.data,
});
var tOptions = new (editComponentsRegistry.get('tEsc'))(parent, {
node: {
attrs: {
't-esc': 'o.child.getCompany()',
'data-oe-id': 99,
'data-oe-xpath': '/my/node/path/',
},
context: {"o": "model.test"},
},
context: {},
state: null,
models: {"model.test": "Model Test"},
});
await tOptions.appendTo(parent.$el);
await testUtils.nextTick();
// the component can not parse the value so we display a simple input
assert.strictEqual(tOptions.$('input[name="t-esc"]').val(),
"o.child.getCompany()",
"Should display the company field");
parent.destroy();
});
QUnit.test('contact: many2many_select', async function (assert) {
assert.expect(11);
var parent = new Widget();
$('ul.ui-autocomplete').remove(); // clean the body to avoid errors due to another test
var optionsFields;
await testUtils.mock.addMockEnvironment(parent, {
intercepts: {
view_change: function (ev) {
assert.deepEqual(ev.data.operation.new_attrs['t-options-fields'], optionsFields,
'Should save the contact options');
params.node.attrs['t-options-fields'] = JSON.stringify(ev.data.operation.new_attrs['t-options-fields']);
},
},
});
await parent.appendTo($('#qunit-fixture'));
var params = {
widgetsOptions: this.widgetsOptions,
node: {
attrs: {
't-options': '{"widget": "contact"}',
't-options-no_marker': 'True',
'data-oe-id': 99,
'data-oe-xpath': '/my/node/path/',
},
},
context: {},
state: null,
models: null,
};
var tOptions = new (editComponentsRegistry.get('tOptions'))(parent, params);
await tOptions.appendTo(parent.$el);
assert.containsN(tOptions, '.o_web_studio_toption_option', 3,
"there should be 3 available options for the contact widget (they are filtered)");
assert.strictEqual(tOptions.$('.o_badge_text').text(), 'NameAddressPhoneMobileEmail', 'Should display default value');
await testUtils.dom.click(tOptions.$('.o_input_dropdown input'));
assert.strictEqual($('ul.ui-autocomplete .ui-menu-item').length, 1, 'Should not display the unselected items');
assert.strictEqual($('ul.ui-autocomplete .o_m2o_dropdown_option').length, 0, 'Should not display create button');
optionsFields = ["name", "address", "phone", "mobile", "email", "vat"];
await testUtils.dom.click($('ul.ui-autocomplete .ui-menu-item:contains(VAT)'));
tOptions.destroy();
tOptions = new (editComponentsRegistry.get('tOptions'))(parent, params);
await tOptions.appendTo(parent.$el);
assert.strictEqual(tOptions.$('.o_badge_text').text(), 'NameAddressPhoneMobileEmailVAT', 'Should display the new value');
await testUtils.dom.click(tOptions.$('.o_input_dropdown input'));
assert.strictEqual($('ul.ui-autocomplete .ui-menu-item').length, 0, 'Should not display the unselected items');
await testUtils.dom.click(tOptions.$('.o_input_dropdown input'));
optionsFields = ["address", "phone", "mobile", "email", "vat"];
await testUtils.dom.click(tOptions.$('.o_field_many2manytags .o_delete:first'));
assert.strictEqual(tOptions.$('.o_badge_text').text(), 'AddressPhoneMobileEmailVAT', 'Should display the new value without "name"');
optionsFields = ["phone", "mobile", "email", "vat"];
await testUtils.dom.click(tOptions.$('.o_field_many2manytags .o_delete:first'));
assert.strictEqual(tOptions.$('.o_badge_text').text(), 'PhoneMobileEmailVAT', 'Should display the new value without "address"');
parent.destroy();
});
QUnit.test('contact: address separator', async function (assert) {
assert.expect(3);
var parent = new Widget();
var addressSeparator;
await testUtils.mock.addMockEnvironment(parent, {
intercepts: {
view_change: function (ev) {
assert.strictEqual(ev.data.operation.new_attrs['t-options-separator'], addressSeparator,
'Should save the selected address separator');
},
},
});
await parent.appendTo($('#qunit-fixture'));
var params = {
widgetsOptions: this.widgetsOptions,
node: {
attrs: {
't-options': '{"widget": "contact"}',
't-options-no_marker': 'True',
'data-oe-id': 99,
'data-oe-xpath': '/my/node/path/',
},
},
context: {},
state: null,
models: null,
};
var tOptions = new (editComponentsRegistry.get('tOptions'))(parent, params);
await tOptions.appendTo(parent.$el);
var separators = _.map(tOptions.$('.o_web_studio_toption_option_contact_separator .o_field_widget option'), function (option) {
return JSON.parse($(option).val());
});
assert.deepEqual(separators, [false, " ", ",", "-", "|", "/"], 'There should be a selection field with proper values');
assert.strictEqual(tOptions.$('.o_web_studio_toption_option_contact_separator .o_field_widget option:selected').text(), 'Linebreak',
'Default value should be "LineBrak"');
addressSeparator = '","';
await testUtils.fields.editSelect(tOptions.$('.o_web_studio_toption_option_contact_separator .o_field_widget'), addressSeparator);
parent.destroy();
});
QUnit.test('contact: no_marker boolean field', async function (assert) {
assert.expect(2);
var parent = new Widget();
await testUtils.mock.addMockEnvironment(parent, {
intercepts: {
view_change: function (ev) {
assert.strictEqual(ev.data.operation.new_attrs["t-options-no_marker"], false,
'Toggling no_marker checkbox should change the option value');
},
},
});
await parent.appendTo($('#qunit-fixture'));
var params = {
widgetsOptions: this.widgetsOptions,
node: {
attrs: {
't-options': '{"widget": "contact"}',
't-options-no_marker': 'True',
'data-oe-id': 99,
'data-oe-xpath': '/my/node/path/',
},
},
context: {},
state: null,
models: null,
};
var tOptions = new (editComponentsRegistry.get('tOptions'))(parent, params);
await tOptions.appendTo(parent.$el);
assert.containsOnce(tOptions, '.o_web_studio_toption_option_contact_no_marker input:checked',
"no_marker checkbox is checked initially");
await testUtils.dom.click(tOptions.$('.o_web_studio_toption_option_contact_no_marker input'));
parent.destroy();
});
QUnit.test('no search more in many2many_select', async function (assert) {
assert.expect(3);
var parent = new Widget();
$('ul.ui-autocomplete').remove(); // clean the body to avoid errors due to another test
await parent.appendTo($('#qunit-fixture'));
// to display more options in the many2many_select
this.widgetsOptions.contact.fields.default_value = [];
var tOptions = new (editComponentsRegistry.get('tOptions'))(parent, {
widgetsOptions: this.widgetsOptions,
node: {
attrs: {
't-options': '{"widget": "contact"}',
't-options-no_marker': 'True',
'data-oe-id': 99,
'data-oe-xpath': '/my/node/path/',
},
},
context: {},
state: null,
models: null,
});
await tOptions.appendTo(parent.$el);
assert.strictEqual(tOptions.$('.o_badge_text').text(), '', 'Should display default value');
await testUtils.dom.click(tOptions.$('.o_input_dropdown input'));
assert.strictEqual($('ul.ui-autocomplete .ui-menu-item').length, 6 , 'Should not display the unselected items');
assert.strictEqual($('ul.ui-autocomplete .o_m2o_dropdown_option').length, 0, 'Should not display create button nor the search more');
parent.destroy();
});
QUnit.test('groups component', async function (assert) {
assert.expect(3);
var parent = new Widget();
await parent.appendTo($('#qunit-fixture'));
var groups = new (editComponentsRegistry.get('groups'))(parent, {
widgets: this.widgets,
node: {
tag: 'span',
attrs: {
studio_groups: "[" +
"{\"name\": \"group_A\", \"display_name\": \"My Awesome Group\", \"id\": 42}," +
"{\"name\": \"group_13\", \"display_name\": \"Kikou\", \"id\": 13}" +
"]",
},
},
});
await groups.appendTo(parent.$el);
assert.containsN(groups, '.o_field_many2manytags .o_badge_text', 2,
"there should be displayed two groups");
assert.strictEqual(groups.$('.o_field_many2manytags').text().replace(/\s/g, ''), "MyAwesomeGroupKikou",
"the groups should be correctly set");
// delete a group
await testUtils.mock.addMockEnvironment(parent, {
intercepts: {
view_change: function (ev) {
assert.deepEqual(ev.data.operation.new_attrs, {groups: [13]},
"should correctly delete the group");
},
},
});
await testUtils.dom.click(groups.$('.o_field_many2manytags .o_delete:first'));
parent.destroy();
});
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,914 @@
odoo.define('web_studio.ReportEditorSidebar_tests', function (require) {
"use strict";
const { nextTick } = require("@web/../tests/helpers/utils");
var testUtils = require('web.test_utils');
var studioTestUtils = require('web_studio.testUtils');
QUnit.module('Studio', {}, function () {
QUnit.module('ReportEditorSidebar', {
beforeEach: function () {
this.data = {
'report.paperformat': {
fields: {
display_name: {string: "Name", type: "char"},
},
records: [{
id: 42,
display_name: 'My Awesome Format',
}],
},
'res.groups': {
fields: {
display_name: {string: "Name", type: "char"},
},
records: [{
id: 6,
display_name: 'Group6',
}, {
id: 7,
display_name: 'Group7',
}],
},
'x_mymodel': {
fields: {
display_name: {string: "Name", type: "char"},
},
},
};
this.widgetsOptions = {
image: {},
integer: {},
text: {},
};
},
}, function () {
QUnit.test("basic rendering", async function (assert) {
var done = assert.async();
assert.expect(5);
studioTestUtils.createSidebar({
state: { mode: 'report' },
report: {},
}).then(async function (sidebar) {
assert.hasClass(sidebar.$('.o_web_studio_sidebar_header [name="report"]'),'active',
"the report tab should be active");
assert.hasClass(sidebar.$('.o_web_studio_sidebar_header [name="options"]'),'inactive',
"the options tab should be inactive");
testUtils.mock.intercept(sidebar, 'sidebar_tab_changed', function (ev) {
assert.step(ev.data.mode);
});
testUtils.dom.click(sidebar.$('.o_web_studio_sidebar_header [name="new"]'));
assert.verifySteps(['new'], "the sidebar should be updated");
await testUtils.dom.click(sidebar.$('.o_web_studio_sidebar_header [name="options"]'));
assert.verifySteps([], "one should not be able to select options");
sidebar.destroy();
done();
});
});
QUnit.test("'Report' tab behaviour", async function (assert) {
assert.expect(6);
return studioTestUtils.createSidebar({
data: this.data,
state: { mode: 'report' },
report: {
name: 'Kikou',
},
}).then(async function (sidebar) {
assert.hasAttrValue(sidebar.$('.o_web_studio_sidebar_header > .active'), 'name', "report",
"the 'Report' tab should be active");
assert.strictEqual(sidebar.$('input[name="name"]').val(), "Kikou",
"the report name should be displayed");
testUtils.mock.intercept(sidebar, 'studio_edit_report', function (ev) {
if (ev.data.name) {
assert.deepEqual(ev.data, { name: "wow_report" });
} else if ('paperformat_id' in ev.data) {
paperformatValues.push(ev.data);
} else if (ev.data.groups_id) {
assert.deepEqual(ev.data, { groups_id: [7] });
}
});
// edit report name
sidebar.$('input[name="name"]').val("wow_report").trigger('change');
// edit the report paperformat
var paperformatValues = [];
await testUtils.fields.many2one.clickOpenDropdown('paperformat_id');
await testUtils.fields.many2one.clickHighlightedItem('paperformat_id');
assert.deepEqual(paperformatValues, [{ paperformat_id: 42 }]);
// remove the report paperformat
sidebar.$('[name="paperformat_id"] input').val('').trigger('keyup').trigger('focusout');
await testUtils.nextTick();
assert.deepEqual(paperformatValues, [{ paperformat_id: 42 }, { paperformat_id: false }]);
// edit groups
await testUtils.fields.many2one.clickOpenDropdown('groups_id');
await testUtils.fields.many2one.clickItem('groups_id', 'Group7');
sidebar.destroy();
});
});
QUnit.test("'Add' tab behaviour", function (assert) {
var done = assert.async();
assert.expect(2);
studioTestUtils.createSidebar({
state: { mode: 'new' },
}).then(function (sidebar) {
assert.hasAttrValue(sidebar.$('.o_web_studio_sidebar_header > .active'), 'name', "new",
"the 'Add' tab should be active");
assert.ok(sidebar.$('.ui-draggable').length,
"there should be draggable components");
sidebar.destroy();
done();
});
});
QUnit.test("basic 'Options' tab behaviour", function (assert) {
var done = assert.async();
assert.expect(4);
var node = {
node: {
attrs: {
'data-oe-id': '42',
'data-oe-xpath': '/t/t/div',
},
tag: 'span',
$nodes: $(),
},
};
studioTestUtils.createSidebar({
state: {
mode: 'properties',
nodes: [node],
},
}).then(function (sidebar) {
assert.hasAttrValue(sidebar.$('.o_web_studio_sidebar_header > .active'), 'name', "options",
"the 'Options' tab should be active");
assert.containsOnce(sidebar, '.o_web_studio_sidebar_content .collapse',
"there should be one node in the accordion");
assert.hasClass(sidebar.$('.o_web_studio_sidebar_content .collapse'),'show',
"the node should be expanded by default");
// remove the element
testUtils.mock.intercept(sidebar, 'element_removed', function (ev) {
assert.deepEqual(ev.data.node, node.node);
});
testUtils.dom.click(sidebar.$('.o_web_studio_sidebar_content .collapse .o_web_studio_remove'));
sidebar.destroy();
done();
});
});
QUnit.test("'Options' tab with multiple nodes", async function (assert) {
assert.expect(9);
var node1 = {
node: {
attrs: {
'data-oe-id': '42',
'data-oe-xpath': '/t/t/div',
},
tag: 'span',
$nodes: $(),
},
};
var node2 = {
node: {
attrs: {
'data-oe-id': '40',
'data-oe-xpath': '/t/t',
},
tag: 'div',
$nodes: $(),
},
};
const sidebar = await studioTestUtils.createSidebar({
state: {
mode: 'properties',
nodes: [node1, node2],
},
});
assert.hasAttrValue(sidebar.$('.o_web_studio_sidebar_header > .active'), 'name', "options",
"the 'Options' tab should be active");
assert.containsN(sidebar, '.o_web_studio_sidebar_content .card', 2,
"there should be one node in the accordion");
assert.hasClass(sidebar.$('.o_web_studio_sidebar_content .card:has(.o_text:contains(span)) .collapse'),'show',
"the 'span' node should be expanded by default");
assert.doesNotHaveClass(sidebar.$('.o_web_studio_sidebar_content .card:has(.o_text:contains(div)) .collapse'), 'show',
"the 'div' node shouldn't be expanded");
assert.strictEqual(sidebar.$('.o_web_studio_sidebar_content .o_web_studio_accordion > .card:last .card-header:first').text().trim(), "span",
"the last node should be the span");
// expand the first node
// BS4 collapsing is asynchronous
await new Promise((resolve) => {
$(document.body).one("hidden.bs.collapse", () => {
resolve();
});
testUtils.dom.click(sidebar.$('.o_web_studio_sidebar_content .o_web_studio_accordion > .card:first [data-bs-toggle="collapse"]:first'));
})
// await end of transitions: https://getbootstrap.com/docs/5.0/components/collapse/#example
await nextTick()
assert.doesNotHaveClass(sidebar.$('.o_web_studio_sidebar_content .card:has(.o_text:contains(span)) .collapse:first'), 'show',
"the 'span' node should have been closed");
assert.hasClass(sidebar.$('.o_web_studio_sidebar_content .card:has(.o_text:contains(div)) .collapse:first'),'show',
"the 'div' node should be expanded");
// reexpand the second node
await new Promise((resolve) => {
$(document.body).one("shown.bs.collapse", () => {
resolve();
});
testUtils.dom.click(sidebar.$('.o_web_studio_sidebar_content .o_web_studio_accordion > .card:last [data-bs-toggle="collapse"]:first'));
})
await nextTick();
assert.hasClass(sidebar.$('.o_web_studio_sidebar_content .card:has(.o_text:contains(span)) .collapse:first'),'show',
"the 'span' node should be expanded again");
assert.doesNotHaveClass(sidebar.$('.o_web_studio_sidebar_content .card:has(.o_text:contains(div)) .collapse:first'), 'show',
"the 'div' node shouldn't be expanded anymore");
sidebar.destroy();
});
QUnit.test("'Options' tab with layout component can be expanded", function (assert) {
var done = assert.async();
assert.expect(3);
var node = {
node: {
attrs: {
'data-oe-id': '42',
'data-oe-xpath': '/t/t/div',
},
tag: 'span',
$nodes: $(),
},
};
studioTestUtils.createSidebar({
state: {
mode: 'properties',
nodes: [node],
},
}).then(function (sidebar) {
assert.containsOnce(sidebar, '.o_web_studio_sidebar_content .collapse',
"there should be one node in the accordion");
assert.containsOnce(sidebar, '.o_web_studio_sidebar_content .o_web_studio_layout',
"there should be a layout component");
assert.containsOnce(sidebar, '.o_web_studio_sidebar_content .o_web_studio_layout .o_web_studio_margin',
"there should be a margin section in the layout component");
sidebar.destroy();
done();
});
});
QUnit.test("'Options' tab with layout component can be expanded on open ", function (assert) {
var done = assert.async();
assert.expect(1);
var node = {
node: {
attrs: {
'data-oe-id': '42',
'data-oe-xpath': '/t/t/div',
},
tag: 'span',
$nodes: $(),
},
};
studioTestUtils.createSidebar({
state: {
mode: 'properties',
nodes: [node],
},
previousState: {
"42/t/t/div": { 'layout': { showAll: true } }, // opens the layout expanded
},
}).then(function (sidebar) {
assert.equal(sidebar.$('.o_web_studio_width:visible').length, 1);
sidebar.destroy();
done();
});
});
QUnit.test("'Options' tab with layout component can be expanded on open with hierarchy", function (assert) {
var done = assert.async();
assert.expect(2);
var nodes = [
{
context: {'rec': 'int'},
node: {
attrs: {
'data-oe-id': '42',
'data-oe-xpath': '/t/t/t/t/div',
't-esc': 'rec',
},
tag: 'span',
$nodes: $(),
}
},
{
context: {'rec': 'int'},
node: {
attrs: {
'data-oe-id': '42',
'data-oe-xpath': '/t/t/t/t',
't-if': 'o.is_ok',
},
tag: 'div',
$nodes: $(),
},
},
{
context: {'rec': 'int'},
node: {
attrs: {
'data-oe-id': '42',
'data-oe-xpath': '/t/t/t',
't-else': '',
},
tag: 't',
$nodes: $(),
},
},
{
context: {},
node: {
attrs: {
'data-oe-id': '42',
'data-oe-xpath': '/t/t',
't-foreach': '5',
't-as': 'rec',
},
tag: 't',
$nodes: $(),
},
},
{
context: {},
node: {
attrs: {
'data-oe-id': '42',
'data-oe-xpath': '/t',
't-name': 'my.template',
},
tag: 't',
$nodes: $(),
},
},
];
nodes[0].node.parent = nodes[1].node;
nodes[1].node.children = [nodes[0].node]
nodes[1].node.parent = nodes[2].node;
nodes[2].node.children = [nodes[1].node]
nodes[2].node.parent = nodes[3].node;
nodes[3].node.children = [{tag: 'span', attrs: {'t-if': 'false'}, $nodes: $(), parent: nodes[3].node}, nodes[2].node]
nodes[3].node.parent = nodes[4].node;
nodes[4].node.children = [nodes[3].node]
nodes[4].node.parent = null;
studioTestUtils.createSidebar({
state: {
mode: 'properties',
nodes: nodes,
},
previousState: {
"42/t/t/t/t/div": { 'layout': { showAll: true } }, // opens the layout expanded
},
}).then(function (sidebar) {
assert.equal(sidebar.$('.card').length, 5, 'Should have 5 item');
assert.equal(sidebar.$('.card h5 > button').get().map(el => el.textContent.replace(/[\s\n]+/g, ' ').trim()).join(' > '), 't > t [foreach="5"] > t > div > span [rec]', 'Should have all ordered parents');
sidebar.destroy();
done();
});
});
QUnit.test("'Options' tab with widget selection (tOptions) component", function (assert) {
var done = assert.async();
assert.expect(4);
var node = {
context: {
'doc': 'x_mymodel',
},
node: {
attrs: {
'data-oe-id': '42',
'data-oe-xpath': '/t/t/div',
't-field': 'doc.id',
't-options-widget': '"text"',
},
tag: 'span',
$nodes: $(),
},
};
studioTestUtils.createSidebar({
state: {
mode: 'properties',
nodes: [node],
},
widgetsOptions: this.widgetsOptions,
}).then(function (sidebar) {
assert.containsOnce(sidebar, '.o_web_studio_tfield_fieldexpression',
"the t-field component should be displayed");
assert.containsOnce(sidebar, '.o_web_studio_toption_widget',
"the t-options component should be displayed");
assert.strictEqual(sidebar.$('.o_web_studio_toption_widget select').text().replace(/\s/g, ''), "imageintegertext",
"all widgets should be selectable");
assert.strictEqual(sidebar.$('.o_web_studio_toption_widget select').val(), "text",
"the correct widget should be selected");
sidebar.destroy();
done();
});
});
QUnit.test("'Options' tab with FieldSelector does not flicker", async function (assert) {
assert.expect(3);
var def = testUtils.makeTestPromise();
var node = {
context: {
'doc': 'x_mymodel',
},
node: {
attrs: {
'data-oe-id': '42',
'data-oe-xpath': '/t/t/div',
't-field': 'doc.id',
't-options-widget': '"text"',
},
context: {
'doc': 'x_mymodel',
},
tag: 'span',
$nodes: $(),
},
};
var sidebarDef = studioTestUtils.createSidebar({
data: this.data,
models: {
'x_mymodel': 'My Model',
},
state: {
mode: 'properties',
nodes: [node],
},
widgetsOptions: this.widgetsOptions,
mockRPC: function (route, args) {
if (args.model === 'x_mymodel' && args.method === 'fields_get') {
// Block the 'read' call
var result = this._super.apply(this, arguments);
return Promise.resolve(def).then(_.constant(result));
}
return this._super.apply(this, arguments);
},
});
await testUtils.nextTick();
assert.strictEqual($('.o_web_studio_tfield_fieldexpression').length, 0,
"the sidebar should wait its components to be rendered before its insertion");
// release the fields_get
def.resolve();
var sidebar = await sidebarDef;
await testUtils.nextTick();
assert.strictEqual($('.o_web_studio_tfield_fieldexpression').length, 1,
"the t-field component should be displayed");
assert.strictEqual(sidebar.$('.o_web_studio_tfield_fieldexpression .o_field_selector_value').text().replace(/\s/g, ''),
"doc(MyModel)ID",
"the field chain should be correctly displayed");
sidebar.destroy();
});
QUnit.test('Various layout changes', function (assert) {
var done = assert.async();
// this test is a combinaison of multiple tests, to avoid copy
// pasting multiple times de sidebar create/intercept/destroy
var layoutChangeNode = {
attrs: {
'data-oe-id': '99',
'data-oe-xpath': '/t/t/div',
},
tag: 'div',
$nodes: $(),
};
var layoutChangeTextNode = {
attrs: {
'data-oe-id': '99',
'data-oe-xpath': '/t/t/span',
},
tag: 'span',
$nodes: $(),
};
var nodeWithAllLayoutPropertiesSet = {
tag: "div",
attrs: {
//width: "1",
style: "margin-top:2px;width:1px;margin-right:3px;margin-bottom:4px;margin-left:5px;",
class: "o_bold o_italic h3 bg-o-color-3 text-o-color-2 o_underline",
'data-oe-id': '99',
'data-oe-xpath': '/t/t/div',
},
$nodes: $(),
};
var nodeWithAllLayoutPropertiesFontAndBackgroundSet = {
tag: "div",
attrs: {
//width: "1",
style: "margin-top:2px;margin-right:3px;width:1px;margin-bottom:4px;margin-left:5px;background-color:#00FF00;color:#00FF00",
class: "o_bold o_italic h3 o_underline",
'data-oe-id': '99',
'data-oe-xpath': '/t/t/div',
},
$nodes: $(),
};
var layoutChangesOperations = [
{
testName: "add a margin top in pixels",
nodeToUse: layoutChangeNode,
eventToTrigger: "change",
sidebarOperationInputSelector: '.o_web_studio_margin [data-margin="margin-top"]',
valueToPut: "42",
expectedRPC: {
inheritance: [{
content: "<attribute name=\"style\" separator=\";\" add=\"margin-top:42px\"/>",
position: "attributes",
view_id: 99,
xpath: "/t/t/div"
}]
}
}, {
testName: "add a margin bottom in pixels",
nodeToUse: layoutChangeNode,
eventToTrigger: "change",
sidebarOperationInputSelector: '.o_web_studio_margin [data-margin="margin-bottom"]',
valueToPut: "42",
expectedRPC: {
inheritance: [{
content: "<attribute name=\"style\" separator=\";\" add=\"margin-bottom:42px\"/>",
position: "attributes",
view_id: 99,
xpath: "/t/t/div"
}]
}
}, {
testName: "add a margin left in pixels",
nodeToUse: layoutChangeNode,
eventToTrigger: "change",
sidebarOperationInputSelector: '.o_web_studio_margin [data-margin="margin-left"]',
valueToPut: "42",
expectedRPC: {
inheritance: [{
content: "<attribute name=\"style\" separator=\";\" add=\"margin-left:42px\"/>",
position: "attributes",
view_id: 99,
xpath: "/t/t/div"
}]
}
}, {
testName: "add a margin right in pixels",
nodeToUse: layoutChangeNode,
eventToTrigger: "change",
sidebarOperationInputSelector: '.o_web_studio_margin [data-margin="margin-right"]',
valueToPut: "42",
expectedRPC: {
inheritance: [{
content: "<attribute name=\"style\" separator=\";\" add=\"margin-right:42px\"/>",
position: "attributes",
view_id: 99,
xpath: "/t/t/div"
}]
}
}, {
testName: "add a width",
nodeToUse: layoutChangeNode,
eventToTrigger: "change",
sidebarOperationInputSelector: '.o_web_studio_width input',
valueToPut: "42",
expectedRPC: {
inheritance: [{
content: "<attribute name=\"style\" separator=\";\" add=\"width:42px\"/>",
position: "attributes",
view_id: 99,
xpath: "/t/t/div"
}]
}
}, {
testName: "add a width on a text",
nodeToUse: layoutChangeTextNode,
eventToTrigger: "change",
sidebarOperationInputSelector: '.o_web_studio_width input',
valueToPut: "42",
expectedRPC: {
inheritance: [{
content: "<attribute name=\"style\" separator=\";\" add=\"width:42px;display:inline-block\"/>",
position: "attributes",
view_id: 99,
xpath: "/t/t/span"
}]
}
}, {
testName: "add a class",
nodeToUse: layoutChangeNode,
eventToTrigger: "change",
sidebarOperationInputSelector: '.o_web_studio_classes input',
valueToPut: "new_class",
expectedRPC: {
new_attrs: {
class: "new_class"
},
type: "attributes",
},
}, {
testName: "set the heading level",
nodeToUse: layoutChangeNode,
eventToTrigger: "click",
sidebarOperationInputSelector: '.o_web_studio_font_size .dropdown-item-text[data-value="h3"]',
expectedRPC: {
inheritance: [{
content: "<attribute name=\"class\" separator=\" \" add=\"h3\"/>",
position: "attributes",
view_id: 99,
xpath: "/t/t/div"
}]
},
}, {
testName: "set the background color to a theme color",
nodeToUse: layoutChangeNode,
eventToTrigger: "mousedown",
sidebarOperationInputSelector: '.o_web_studio_colors .o_web_studio_background_colorpicker button[data-color="o-color-3"]',
expectedRPC: {
inheritance: [{
content: "<attribute name=\"class\" separator=\" \" add=\"bg-o-color-3\"/>",
position: "attributes",
view_id: 99,
xpath: "/t/t/div"
}]
},
}, {
testName: "set the background color to a standard color",
nodeToUse: layoutChangeNode,
eventToTrigger: "mousedown",
sidebarOperationInputSelector: '.o_web_studio_colors .o_web_studio_background_colorpicker button[data-value="#00FF00"]',
valueToPut: "h3",
expectedRPC: {
inheritance: [{
content: "<attribute name=\"style\" separator=\";\" add=\"background-color:#00FF00\"/>",
position: "attributes",
view_id: 99,
xpath: "/t/t/div"
}]
},
}, {
testName: "set the font color to a theme color",
nodeToUse: layoutChangeNode,
eventToTrigger: "mousedown",
sidebarOperationInputSelector: '.o_web_studio_colors .o_web_studio_font_colorpicker button[data-color="o-color-3"]',
expectedRPC: {
inheritance: [{
content: "<attribute name=\"class\" separator=\" \" add=\"text-o-color-3\"/>",
position: "attributes",
view_id: 99,
xpath: "/t/t/div"
}]
},
}, {
testName: "set the font color to a standard color",
nodeToUse: layoutChangeNode,
eventToTrigger: "mousedown",
sidebarOperationInputSelector: '.o_web_studio_colors .o_web_studio_font_colorpicker button[data-value="#00FF00"]',
valueToPut: "h3",
expectedRPC: {
inheritance: [{
content: "<attribute name=\"style\" separator=\";\" add=\"color:#00FF00\"/>",
position: "attributes",
view_id: 99,
xpath: "/t/t/div"
}]
},
}, {
testName: "set the alignment",
nodeToUse: layoutChangeNode,
eventToTrigger: "click",
sidebarOperationInputSelector: '.o_web_studio_text_alignment button[title="end"]',
expectedRPC: {
inheritance: [{
content: "<attribute name=\"class\" separator=\" \" add=\"text-end\"/>",
position: "attributes",
view_id: 99,
xpath: "/t/t/div"
}]
},
}, {
testName: "remove margin top in pixels",
nodeToUse: nodeWithAllLayoutPropertiesSet,
eventToTrigger: "change",
sidebarOperationInputSelector: '.o_web_studio_margin [data-margin="margin-top"]',
valueToPut: "",
expectedRPC: {
inheritance: [{
content: "<attribute name=\"style\" separator=\";\" remove=\"margin-top:2px\"/>",
position: "attributes",
view_id: 99,
xpath: "/t/t/div"
}]
}
}, {
testName: "remove a margin bottom in pixels",
nodeToUse: nodeWithAllLayoutPropertiesSet,
eventToTrigger: "change",
sidebarOperationInputSelector: '.o_web_studio_margin [data-margin="margin-bottom"]',
valueToPut: "",
expectedRPC: {
inheritance: [{
content: "<attribute name=\"style\" separator=\";\" remove=\"margin-bottom:4px\"/>",
position: "attributes",
view_id: 99,
xpath: "/t/t/div"
}]
}
}, {
testName: "remove a margin left in pixels",
nodeToUse: nodeWithAllLayoutPropertiesSet,
eventToTrigger: "change",
sidebarOperationInputSelector: '.o_web_studio_margin [data-margin="margin-left"]',
valueToPut: "",
expectedRPC: {
inheritance: [{
content: "<attribute name=\"style\" separator=\";\" remove=\"margin-left:5px\"/>",
position: "attributes",
view_id: 99,
xpath: "/t/t/div"
}]
}
}, {
testName: "remove a margin right in pixels",
nodeToUse: nodeWithAllLayoutPropertiesSet,
eventToTrigger: "change",
sidebarOperationInputSelector: '.o_web_studio_margin [data-margin="margin-right"]',
valueToPut: "",
expectedRPC: {
inheritance: [{
content: "<attribute name=\"style\" separator=\";\" remove=\"margin-right:3px\"/>",
position: "attributes",
view_id: 99,
xpath: "/t/t/div"
}]
}
}, {
testName: "remove the width",
nodeToUse: nodeWithAllLayoutPropertiesSet,
eventToTrigger: "change",
sidebarOperationInputSelector: '.o_web_studio_width input',
valueToPut: "",
expectedRPC: {
inheritance: [{
content: "<attribute name=\"style\" separator=\";\" remove=\"width:1px\"/>",
position: "attributes",
view_id: 99,
xpath: "/t/t/div"
}]
}
}, {
testName: "remove a class",
nodeToUse: nodeWithAllLayoutPropertiesSet,
eventToTrigger: "change",
sidebarOperationInputSelector: '.o_web_studio_classes input',
valueToPut: "o_bold o_italic bg-o-color-3 text-o-color-2 o_underline",
expectedRPC: {
new_attrs: {
class: "o_bold o_italic bg-o-color-3 text-o-color-2 o_underline"
},
type: "attributes",
},
}, {
testName: "unset the background color to a theme color",
nodeToUse: nodeWithAllLayoutPropertiesSet,
eventToTrigger: "click",
sidebarOperationInputSelector: '.o_web_studio_colors .o_web_studio_background_colorpicker .o_web_studio_reset_color',
expectedRPC: {
inheritance: [{
content: "<attribute name=\"class\" separator=\" \" remove=\"bg-o-color-3\"/>",
position: "attributes",
view_id: 99,
xpath: "/t/t/div"
}]
},
},{
testName: "unset the background color to a standard color",
nodeToUse: nodeWithAllLayoutPropertiesFontAndBackgroundSet,
eventToTrigger: "click",
sidebarOperationInputSelector: '.o_web_studio_colors .o_web_studio_background_colorpicker .o_web_studio_reset_color',
expectedRPC: {
inheritance: [{
content: "<attribute name=\"style\" separator=\";\" remove=\"background-color:#00FF00\"/>",
position: "attributes",
view_id: 99,
xpath: "/t/t/div"
}]
},
}, {
testName: "unset the font color to a theme color",
nodeToUse: nodeWithAllLayoutPropertiesSet,
eventToTrigger: "click",
sidebarOperationInputSelector: '.o_web_studio_colors .o_web_studio_font_colorpicker .o_web_studio_reset_color',
expectedRPC: {
inheritance: [{
content: "<attribute name=\"class\" separator=\" \" remove=\"text-o-color-2\"/>",
position: "attributes",
view_id: 99,
xpath: "/t/t/div"
}]
},
}, {
testName: "unset the font color to a standard color",
nodeToUse: nodeWithAllLayoutPropertiesFontAndBackgroundSet,
eventToTrigger: "click",
sidebarOperationInputSelector: '.o_web_studio_colors .o_web_studio_font_colorpicker button.o_web_studio_reset_color',
expectedRPC: {
inheritance: [{
content: "<attribute name=\"style\" separator=\";\" remove=\"color:#00FF00\"/>",
position: "attributes",
view_id: 99,
xpath: "/t/t/div"
}]
},
},
];
// there is one assert by operation
assert.expect(layoutChangesOperations.length);
var initialDebugMode = odoo.debug;
// show 'class' in the sidebar
odoo.debug = true;
function poll (changeOperation) {
var node = {
node: changeOperation.nodeToUse,
};
studioTestUtils.createSidebar({
state: {
mode: 'properties',
nodes: [node],
},
previousState: {
"99/t/t/div": { 'layout': { showAll: true } }, // opens the layout expanded
},
}).then(function (sidebar) {
testUtils.mock.intercept(sidebar, 'view_change', function (ev) {
assert.deepEqual(ev.data.operation, changeOperation.expectedRPC, changeOperation.testName);
});
sidebar.$(changeOperation.sidebarOperationInputSelector)
.val(changeOperation.valueToPut)
.trigger(changeOperation.eventToTrigger);
sidebar.destroy();
}).then(function () {
if (layoutChangesOperations.length) {
poll(layoutChangesOperations.shift());
} else {
odoo.debug = initialDebugMode;
done();
}
});
}
poll(layoutChangesOperations.shift());
});
});
});
});

View File

@@ -0,0 +1,46 @@
odoo.define('web_studio.ReportEditor_tests', function (require) {
"use strict";
var studioTestUtils = require('web_studio.testUtils');
QUnit.module('Studio', {}, function () {
QUnit.module('ReportEditor', {
beforeEach: function () {
},
}, function () {
QUnit.test('basic report rendering', async function (assert) {
assert.expect(2);
var nodesArchs = {
42: {
attrs: {
'data-oe-id': '42',
'data-oe-xpath': '/t',
name: "Layout",
't-name': '42',
},
},
id: 42,
key: 'report.layout',
parent: null,
tag: 't',
};
var reportHTML = "<html><body><t/></body></html>";
var editor = await studioTestUtils.createReportEditor({
nodesArchs: nodesArchs,
reportHTML: reportHTML,
});
assert.containsOnce(editor, 'iframe',
"an iframe should be rendered");
assert.hasAttrValue(editor.$('iframe'), 'src', "about:blank",
"the source should be correctly set");
editor.destroy();
});
});
});
});

View File

@@ -0,0 +1,354 @@
odoo.define('web_studio.testUtils', function (require) {
"use strict";
const { start } = require('@mail/../tests/helpers/test_utils');
var dom = require('web.dom');
const MockServer = require('web.MockServer');
const { ComponentAdapter } = require('web.OwlCompatibility');
var QWeb = require('web.QWeb');
const { registry } = require('@web/core/registry');
var testUtils = require('web.test_utils');
var utils = require('web.utils');
var Widget = require('web.Widget');
var ReportEditor = require('web_studio.ReportEditor');
var ReportEditorManager = require('web_studio.ReportEditorManager');
var ReportEditorSidebar = require('web_studio.ReportEditorSidebar');
var ViewEditorManager = require('web_studio.ViewEditorManager');
var weTestUtils = require('web_editor.test_utils');
const { registerCleanup } = require("@web/../tests/helpers/cleanup");
/**
* Create a ReportEditorManager widget.
*
* @param {Object} params
* @return {ReportEditorManager}
*/
async function createReportEditor(params) {
var Parent = Widget.extend({
start: function () {
var self = this;
this._super.apply(this, arguments).then(function () {
var $studio = $.parseHTML(
"<div class='o_web_studio_client_action'>" +
"<div class='o_web_studio_editor_manager o_web_studio_report_editor_manager'/>" +
"</div>" +
"</div>");
self.$el.append($studio);
});
},
});
var parent = new Parent();
weTestUtils.patch();
params.data = weTestUtils.wysiwygData(params.data);
await testUtils.mock.addMockEnvironment(parent, params);
var selector = params.debug ? 'body' : '#qunit-fixture';
return parent.appendTo(selector).then(function () {
var editor = new ReportEditor(parent, params);
// override 'destroy' of editor so that it calls 'destroy' on the parent
// instead
editor.destroy = function () {
// remove the override to properly destroy editor and its children
// when it will be called the second time (by its parent)
delete editor.destroy;
// TODO: call super?
parent.destroy();
weTestUtils.unpatch();
};
return editor.appendTo(parent.$('.o_web_studio_editor_manager')).then(function () {
return editor;
});
});
}
/**
* Create a ReportEditorManager widget.
*
* @param {Object} params
* @return {Promise<ReportEditorManager>}
*/
async function createReportEditorManager(params) {
var parent = new StudioEnvironment();
await testUtils.mock.addMockEnvironment(parent, params);
weTestUtils.patch();
params.data = weTestUtils.wysiwygData(params.data);
var rem = new ReportEditorManager(parent, params);
// also destroy to parent widget to avoid memory leak
rem.destroy = function () {
delete rem.destroy;
parent.destroy();
weTestUtils.unpatch();
};
var fragment = document.createDocumentFragment();
var selector = params.debug ? 'body' : '#qunit-fixture';
if (params.debug) {
$('body').addClass('debug');
}
await parent.prependTo(selector);
await rem.appendTo(fragment)
// use dom.append to call on_attach_callback
dom.append(parent.$('.o_web_studio_client_action'), fragment, {
callbacks: [{widget: rem}],
in_DOM: true,
});
await rem.editorIframeDef
return rem;
}
/**
* Create a sidebar widget.
*
* @param {Object} params
* @return {Promise<ReportEditorSidebar>}
*/
async function createSidebar(params) {
var Parent = Widget.extend({
start: function () {
var self = this;
this._super.apply(this, arguments).then(function () {
var $studio = $.parseHTML(
"<div class='o_web_studio_client_action'>" +
"<div class='o_web_studio_editor_manager o_web_studio_report_editor_manager'/>" +
"</div>");
self.$el.append($studio);
});
},
});
var parent = new Parent();
weTestUtils.patch();
params.data = weTestUtils.wysiwygData(params.data);
await testUtils.mock.addMockEnvironment(parent, params);
var sidebar = new ReportEditorSidebar(parent, params);
sidebar.destroy = function () {
// remove the override to properly destroy sidebar and its children
// when it will be called the second time (by its parent)
delete sidebar.destroy;
parent.destroy();
weTestUtils.unpatch();
};
var selector = params.debug ? 'body' : '#qunit-fixture';
if (params.debug) {
$('body').addClass('debug');
}
parent.appendTo(selector);
var fragment = document.createDocumentFragment();
return sidebar.appendTo(fragment).then(function () {
sidebar.$el.appendTo(parent.$('.o_web_studio_editor_manager'));
return sidebar;
});
}
/**
* This class let us instanciate a widget via createWebClient and get it
* afterwards in order to use it during tests.
*/
const { onMounted, onWillUnmount } = owl;
class StudioEnvironmentComponent extends ComponentAdapter {
constructor() {
super(...arguments);
this.env = owl.Component.env;
onMounted(() => {
StudioEnvironmentComponent.currentWidget = this.widget;
});
onWillUnmount(() => {
StudioEnvironmentComponent.currentWidget = undefined;
});
}
}
/**
* Create a ViewEditorManager widget.
*
* @param {Object} params
* @return {ViewEditorManager}
*/
async function createViewEditorManager(params) {
weTestUtils.patch();
params.serverData = params.serverData || {};
params.serverData.models = params.serverData.models || {};
params.serverData.models = weTestUtils.wysiwygData(params.serverData.models);
registry.category('main_components').add('StudioEnvironmentContainer', {
Component: StudioEnvironmentComponent,
props: { Component: StudioEnvironment },
});
const { env: wowlEnv } = await start({
...params,
legacyParams: { withLegacyMockServer: true },
});
const parent = StudioEnvironmentComponent.currentWidget;
const fieldsView = testUtils.mock.getView(MockServer.currentMockServer, params);
if (params.viewID) {
fieldsView.view_id = params.viewID;
}
const type = fieldsView.type;
const viewDescriptions = {
fields: fieldsView.fields,
relatedModels: {
[params.model]: fieldsView.fields,
},
views: {
[type]: {
arch: fieldsView.arch,
id: fieldsView.view_id || false,
custom_view_id: null,
},
}
}
const vem = new ViewEditorManager(parent, {
action: {
context: params.context || {},
domain: params.domain || [],
res_model: params.model,
},
controllerState: {
currentId: 'res_id' in params ? params.res_id : undefined,
resIds: 'res_id' in params ? [params.res_id] : undefined,
},
fields_view: fieldsView,
viewType: fieldsView.type,
studio_view_id: params.studioViewID,
chatter_allowed: params.chatter_allowed,
wowlEnv,
viewDescriptions,
});
registerCleanup(() => {
vem.destroy();
weTestUtils.unpatch();
});
const fragment = document.createDocumentFragment();
await parent.prependTo(testUtils.prepareTarget(params.debug));
await vem.appendTo(fragment);
dom.append(parent.$('.o_web_studio_client_action'), fragment, {
callbacks: [{widget: vem}],
in_DOM: true,
});
return vem;
}
/**
* Renders a list of templates.
*
* @param {Array<Object>} templates
* @param {Object} data
* @param {String} [data.dataOeContext]
* @returns {string}
*/
function getReportHTML(templates, data) {
_brandTemplates(templates, data && data.dataOeContext);
var qweb = new QWeb();
_.each(templates, function (template) {
qweb.add_template(template.arch);
});
var render = qweb.render('template0', data);
return render;
}
/**
* Builds the report views object.
*
* @param {Array<Object>} templates
* @param {Object} [data]
* @param {String} [data.dataOeContext]
* @returns {Object}
*/
function getReportViews(templates, data) {
_brandTemplates(templates, data && data.dataOeContext);
var reportViews = {};
_.each(templates, function (template) {
reportViews[template.view_id] = {
arch: template.arch,
key: template.key,
studio_arch: '</data>',
studio_view_id: false,
view_id: template.view_id,
};
});
return reportViews;
}
/**
* Brands (in place) a list of templates.
*
* @private
* @param {Array<Object>} templates
* @param {String} [dataOeContext]
*/
function _brandTemplates(templates, dataOeContext) {
_.each(templates, function (template) {
brandTemplate(template);
});
function brandTemplate(template) {
var doc = $.parseXML(template.arch).documentElement;
var rootNode = utils.xml_to_json(doc, true);
brandNode([rootNode], rootNode, '');
function brandNode(siblings, node, xpath) {
// do not brand already branded nodes
if (_.isObject(node) && !node.attrs['data-oe-id']) {
if (node.tag !== 'kikou') {
xpath += ('/' + node.tag);
var index = _.filter(siblings, {tag: node.tag}).indexOf(node);
if (index > 0) {
xpath += '[' + index + ']';
}
node.attrs['data-oe-id'] = template.view_id;
node.attrs['data-oe-xpath'] = xpath;
node.attrs['data-oe-context'] = dataOeContext || '{}';
}
_.each(node.children, function (child) {
brandNode(node.children, child, xpath);
});
}
}
template.arch = utils.json_node_to_xml(rootNode);
}
}
var StudioEnvironment = Widget.extend({
className: 'o_web_client o_in_studio',
start: function () {
var self = this;
this._super.apply(this, arguments).then(function () {
// reproduce the DOM environment of Studio
var $studio = $.parseHTML(
"<div class='o_content'>" +
"<div class='o_web_studio_client_action'/>" +
"</div>"
);
self.$el.append($studio);
});
},
});
return {
createReportEditor: createReportEditor,
createReportEditorManager: createReportEditorManager,
createSidebar: createSidebar,
createViewEditorManager: createViewEditorManager,
getData: weTestUtils.wysiwygData,
getReportHTML: getReportHTML,
getReportViews: getReportViews,
patch: weTestUtils.patch,
unpatch: weTestUtils.unpatch,
};
});

View File

@@ -0,0 +1,990 @@
odoo.define('web_studio.tests.tour', function (require) {
"use strict";
const localStorage = require('web.local_storage');
const tour = require('web_tour.tour');
const { randomString } = require('web_studio.utils');
let createdAppString = null;
let createdMenuString = null;
tour.register('web_studio_tests_tour', {
url: "/web",
test: true,
}, [{
// open studio
trigger: '.o_main_navbar .o_web_studio_navbar_item',
}, {
trigger: '.o_web_studio_new_app',
}, {
// the next steps are here to create a new app
trigger: '.o_web_studio_app_creator_next',
}, {
trigger: '.o_web_studio_app_creator_name > input',
run: 'text ' + (createdAppString = randomString(6)),
}, {
trigger: '.o_web_studio_app_creator_next.is_ready',
}, {
trigger: '.o_web_studio_app_creator_menu > input',
run: 'text ' + (createdMenuString = randomString(6)),
}, {
trigger: '.o_web_studio_app_creator_next.is_ready',
}, {
// disable chatter in model configurator, we'll test adding it on later
trigger: 'input[name="use_mail"]',
}, {
// disable company if visible, otherwise it might make the test uncertain
trigger: 'body',
run: () => {
const $input = $('input[name="use_company"]');
if ($input) {
$input.click();
}
}
}, {
trigger: '.o_web_studio_model_configurator_next',
}, {
// toggle the home menu outside of studio and come back in studio
extra_trigger: '.o_menu_toggle:not(.o_menu_toggle_back)',
trigger: '.o_web_studio_leave > a.btn',
timeout: 60000, /* previous step reloads registry, etc. - could take a long time */
}, {
extra_trigger: `.o_web_client:not(.o_in_studio)`, /* wait to be out of studio */
trigger: '.o_menu_toggle:not(.o_menu_toggle_back)',
timeout: 60000, /* previous step reloads registry, etc. - could take a long time */
}, {
trigger: '.o_main_navbar .o_web_studio_navbar_item',
extra_trigger: '.o_home_menu',
}, {
// open the app creator and leave it
trigger: '.o_web_studio_new_app',
}, {
extra_trigger: '.o_web_studio_app_creator',
trigger: '.o_web_studio_leave > a.btn',
}, {
// go back to the previous app
trigger: '.o_home_menu',
run: () => {
window.dispatchEvent(new KeyboardEvent('keydown', {
bubbles: true,
key: 'Escape',
}));
},
}, {
// this should open the previous app outside of studio
extra_trigger: `.o_web_client:not(.o_in_studio) .o_menu_brand:contains(${createdAppString})`,
// go back to the home menu
trigger: '.o_menu_toggle:not(.o_menu_toggle_back)',
}, {
trigger: 'input.o_search_hidden',
// Open Command Palette
run: 'text ' + createdMenuString[0],
}, {
trigger: '.o_command_palette_search input',
run: 'text ' + "/" + createdMenuString,
}, {
// search results should have been updated
extra_trigger: `.o_command.focused:contains(${createdAppString} / ${createdMenuString})`,
trigger: '.o_command_palette',
// Close the Command Palette
run: () => {
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape',
}));
},
}, {
// enter Studio
trigger: '.o_main_navbar .o_web_studio_navbar_item',
}, {
// edit an app
extra_trigger: '.o_studio_home_menu',
trigger: `.o_app[data-menu-xmlid*="studio"]:contains(${createdAppString})`,
run: function () {
// We can't emulate a hover to display the edit icon
const editIcon = this.$anchor[0].querySelector('.o_web_studio_edit_icon');
editIcon.style.visibility = 'visible';
editIcon.click();
},
}, {
// design the icon
// TODO: we initially tested this (change an app icon) at the end but a
// long-standing bug (KeyError: ir.ui.menu.display_name, caused by a registry
// issue with multiple workers) on runbot prevent us from doing it. It thus have
// been moved at the beginning of this test to avoid the registry to be reloaded
// before the write on ir.ui.menu.
trigger: '.o_web_studio_selector:eq(0)',
}, {
trigger: '.o_web_studio_palette > .o_web_studio_selector:first',
}, {
trigger: '.modal-footer .btn.btn-primary',
}, {
// click on the created app
trigger: `.o_app[data-menu-xmlid*="studio"]:contains(${createdAppString})`,
}, {
// create a new menu
trigger: '.o_main_navbar .o_web_edit_menu',
}, {
trigger: 'footer.modal-footer .js_add_menu',
}, {
trigger: 'input[name="name"]',
run: 'text ' + (createdMenuString = randomString(6)),
}, {
trigger: 'div[name="model_choice"] input[data-value="existing"]',
}, {
trigger: '.o_field_many2one[name="model"] input',
run: 'text a',
}, {
trigger: '.ui-autocomplete > .ui-menu-item:first > a',
in_modal: false,
}, {
trigger: 'button:contains(Confirm):not(".disabled")',
}, {
trigger: 'button:contains(Confirm):not(".disabled")',
}, {
// check that the Studio menu is still there
extra_trigger: '.o_web_studio_menu',
// switch to form view
trigger: '.o_web_studio_views_icons > a[title="Form"]',
}, {
// wait for the form editor to be rendered because the sidebar is the same
extra_trigger: '.o_web_studio_form_view_editor',
// unfold 'Existing Fieldqs' section
trigger: '.o_web_studio_existing_fields_header',
}, {
// add an new field
trigger: '.o_web_studio_sidebar .o_web_studio_field_type_container:eq(1) .o_web_studio_field_char',
run: 'drag_and_drop .o_web_studio_form_view_editor .o_inner_group',
}, {
// click on the field
trigger: '.o_web_studio_form_view_editor .o_wrap_label:first label',
// when it's there
extra_trigger: 'input[data-type="field_name"]',
}, {
// rename the label
trigger: '.o_web_studio_sidebar_content.o_display_field input[name="string"]',
run: 'text My Coucou Field',
}, {
// verify that the field name has changed and change it
trigger: 'input[data-type="field_name"][value="my_coucou_field"]',
run: 'text coucou',
// the rename operation (/web_studio/rename_field + /web_studio/edit_view)
// takes a while and sometimes reaches the default 10s timeout
timeout: 20000,
}, {
// click on "Add" tab
trigger: '.o_web_studio_sidebar .o_web_studio_new',
// the rename operation (/web_studio/rename_field + /web_studio/edit_view)
// takes a while and sometimes reaches the default 10s timeout
timeout: 20000,
async run() {
// During the rename, the UI is blocked. When the rpc returns, the UI is
// unblocked and the sidebar is re-rendered. Without this, the step is
// sometimes executed exactly when the sidebar is about to be replaced,
// and it doesn't work. We thus here wait for 1s to ensure that the
// sidebar has been re-rendered, before going further.
// note1: there's nothing in the DOM that could be used to determine that
// we're ready to continue (the sidebar is just replaced by itself, same state)
// note2: ideally, it should work whenever we click, but with the current
// architecture of studio, it's really hard to fix. Hopefully, when studio
// will be converted to owl, this should no longer be an issue.
await new Promise((r) => setTimeout(r, 1000));
$(".o_web_studio_sidebar .o_web_studio_new").click();
}
}, {
// add a new field
trigger: '.o_web_studio_sidebar .o_web_studio_field_type_container:eq(1) .o_web_studio_field_char',
run: 'drag_and_drop .o_web_studio_form_view_editor .o_inner_group',
}, {
// rename the field with the same name
trigger: 'input[data-type="field_name"]',
run: 'text coucou',
}, {
// an alert dialog should be opened
trigger: '.modal-footer > button:first',
}, {
// rename the label
trigger: '.o_web_studio_sidebar_content.o_display_field input[name="string"]',
run: 'text COUCOU',
}, {
// verify that the field name has changed (post-fixed by _1)
extra_trigger: 'input[data-type="field_name"][value="coucou_1"]',
trigger: '.o_web_studio_sidebar .o_web_studio_new',
// the rename operation (/web_studio/rename_field + /web_studio/edit_view)
// takes a while and sometimes reaches the default 10s timeout
timeout: 20000,
}, {
// add a monetary field --> create a currency field
trigger: '.o_web_studio_sidebar .o_web_studio_field_type_container:eq(1) .o_web_studio_field_monetary',
run: 'drag_and_drop .o_web_studio_form_view_editor .o_inner_group',
}, {
trigger: '.modal-footer .btn.btn-primary',
}, {
// verify that the currency field is in the view
extra_trigger: '.o_web_studio_form_view_editor .o_wrap_label label:contains("Currency")',
trigger: '.o_web_studio_sidebar .o_web_studio_new',
async run() {
// When adding a new field, the UI is blocked. When the rpc returns, the UI is
// unblocked and the sidebar is re-rendered. Without this, the step is
// sometimes executed exactly when the sidebar is about to be replaced,
// and it doesn't work. We thus here wait for 1s to ensure that the
// sidebar has been re-rendered, before going further.
// note1: there's nothing in the DOM that could be used to determine that
// we're ready to continue (the sidebar is just replaced by itself, same state)
// note2: ideally, it should work whenever we click, but with the current
// architecture of studio, it's really hard to fix. Hopefully, when studio
// will be converted to owl, this should no longer be an issue.
await new Promise((r) => setTimeout(r, 1000));
$(".o_web_studio_sidebar .o_web_studio_new").click();
}
}, {
// add a monetary field
trigger: '.o_web_studio_sidebar .o_web_studio_field_type_container:eq(1) .o_web_studio_field_monetary',
run: 'drag_and_drop (.o_web_studio_form_view_editor .o_inner_group:first .o_web_studio_hook:eq(1))',
}, {
// verify that the monetary field is in the view
extra_trigger: '.o_web_studio_form_view_editor .o_wrap_label:eq(1) label:contains("New Monetary")',
// switch the two first fields
trigger: '.o_web_studio_form_view_editor .o_inner_group:first .o-draggable:eq(1)',
run: 'drag_and_drop_native .o_inner_group:first .o_web_studio_hook:first',
}, {
// click on "Add" tab
trigger: '.o_web_studio_sidebar .o_web_studio_new',
}, {
// verify that the fields have been switched
extra_trigger: '.o_web_studio_form_view_editor .o_wrap_label:eq(0) label:contains("New Monetary")',
// add a m2m field
trigger: '.o_web_studio_sidebar .o_web_studio_field_type_container:eq(1) .o_web_studio_field_many2many',
run: 'drag_and_drop .o_inner_group:first .o_web_studio_hook:first',
}, {
// type something in the modal
trigger: '.o_field_many2one[name="model"] input',
run: 'text a',
}, {
// select the first model
trigger: '.ui-autocomplete > .ui-menu-item:first > a',
in_modal: false,
}, {
trigger: 'button:contains(Confirm)',
}, {
// select the m2m to set its properties
trigger: '.o_wrap_input:has(.o_field_many2many)',
timeout: 15000, // creating M2M relations can take some time...
}, {
// change the `widget` attribute
trigger: '.o_web_studio_sidebar select[name="widget"]',
run: function () {
this.$anchor.val('many2many_tags').trigger('change');
},
}, {
// use colors on the m2m tags
trigger: '.o_web_studio_sidebar label[for="option_color_field"]',
}, {
// add a statusbar
trigger: '.o_web_studio_statusbar_hook',
}, {
trigger: '.modal-footer .btn.btn-primary',
}, {
trigger: '.o_statusbar_status',
}, {
// verify that a default value has been set for the statusbar
trigger: '.o_web_studio_sidebar select[name="default_value"]:contains(First Status)',
}, {
trigger: '.o_web_studio_views_icons a[title=Form]',
}, {
// verify Chatter can be added after changing view to form
extra_trigger: '.o_web_studio_add_chatter',
// edit action
trigger: '.o_web_studio_menu .o_menu_sections li a:contains(Views)',
}, {
// edit form view
trigger: '.o_web_studio_view_category .o_web_studio_view_type[data-type="form"] .o_web_studio_thumbnail',
}, {
// verify Chatter can be added after changing view to form
extra_trigger: '.o_web_studio_add_chatter',
// switch in list view
trigger: '.o_web_studio_menu .o_web_studio_views_icons a[title="List"]',
}, {
// wait for the list editor to be rendered because the sidebar is the same
extra_trigger: '.o_web_studio_list_view_editor',
// unfold 'Existing Fieldqs' section
trigger: '.o_web_studio_existing_fields_header',
}, {
// add an existing field (display_name)
trigger: '.o_web_studio_sidebar .o_web_studio_field_type_container:eq(1) .o_web_studio_field_char',
run: 'drag_and_drop .o_web_studio_list_view_editor th.o_web_studio_hook:first',
}, {
// verify that the field is correctly named
extra_trigger: '.o_web_studio_list_view_editor th:contains("COUCOU")',
// leave Studio
trigger: '.o_web_studio_leave > a.btn',
}, {
// come back to the home menu to check if the menu data have changed
extra_trigger: '.o_web_client:not(.o_in_studio)',
trigger: '.o_menu_toggle:not(.o_menu_toggle_back)',
}, {
trigger: 'input.o_search_hidden',
// Open Command Palette
run: 'text ' + createdMenuString[0],
}, {
trigger: '.o_command_palette_search input',
run: 'text ' + "/" + createdMenuString,
}, {
// search results should have been updated
extra_trigger: `.o_command.focused:contains(${createdAppString} / ${createdMenuString})`,
trigger: '.o_command_palette',
// Close the Command Palette
run: () => {
window.dispatchEvent(new KeyboardEvent('keydown', {
bubbles: true,
key: 'Escape',
}));
},
}, {
trigger: '.o_home_menu',
// go back again to the app (using keyboard)
run: () => {
window.dispatchEvent(new KeyboardEvent('keydown', {
bubbles: true,
key: 'Escape',
}));
},
}, {
// wait to be back in the list view
extra_trigger: '.o_list_view',
// re-open studio
trigger: '.o_web_studio_navbar_item',
}, {
// modify the list view
trigger: '.o_web_studio_sidebar .o_web_studio_view'
}, {
//select field you want to sort and based on that sorting will be applied on List view
trigger: '.o_web_studio_sidebar .o_web_studio_sidebar_select #sort_field',
run: function () {
$('#sort_field option:eq(1)').attr('selected', 'selected');
$('#sort_field option:eq(1)').change();
}
}, {
//change order of sorting, Select order and change it
trigger: '.o_web_studio_sidebar .o_web_studio_sidebar_select #sort_order',
run: function () {
$('#sort_order option:eq(1)').attr('selected', 'selected');
$('#sort_order option:eq(1)').change();
}
}, {
// edit action
trigger: '.o_web_studio_menu .o_menu_sections li a:contains("Views")',
}, {
// add a kanban
trigger: '.o_web_studio_view_category .o_web_studio_view_type.o_web_studio_inactive[data-type="kanban"] .o_web_studio_thumbnail',
}, {
// add a dropdown
trigger: '.o_dropdown_kanban.o_web_studio_add_dropdown',
}, {
trigger: '.modal-footer .btn.btn-primary',
}, {
// select the dropdown for edition
trigger: '.o_dropdown_kanban:not(.o_web_studio_add_dropdown)',
}, {
// enable "Set Cover" feature
trigger: '.o_web_studio_sidebar input[name=set_cover]',
}, {
trigger: '.modal-footer .btn.btn-primary',
}, {
// edit action
trigger: '.o_web_studio_menu .o_menu_sections li a:contains("Views")',
}, {
// check that the kanban view is now active
extra_trigger: '.o_web_studio_view_category .o_web_studio_view_type:not(.o_web_studio_inactive)[data-type="kanban"]',
// add an activity view
trigger: '.o_web_studio_view_category .o_web_studio_view_type.o_web_studio_inactive[data-type="activity"] .o_web_studio_thumbnail',
}, {
extra_trigger: '.o_activity_view',
// edit action
trigger: '.o_web_studio_menu .o_menu_sections li a:contains("Views")',
timeout: 20000, // activating a view takes a while and sometimes reaches the default 10s timeout
}, {
// add a graph view
trigger: '.o_web_studio_view_category .o_web_studio_view_type.o_web_studio_inactive[data-type="graph"] .o_web_studio_thumbnail',
}, {
extra_trigger: '.o_graph_renderer',
trigger: '.o_web_studio_menu .o_menu_sections li a:contains("Views")',
}, {
extra_trigger: '.o_web_studio_views',
// edit the search view
trigger: '.o_web_studio_view_category .o_web_studio_view_type[data-type="search"] .o_web_studio_thumbnail',
}, {
extra_trigger: '.o_web_studio_search_view_editor',
trigger: '.o_menu_toggle:not(.o_menu_toggle_back)',
}, {
trigger: '.o_web_studio_home_studio_menu .dropdown-toggle',
}, {
// export all modifications
trigger: '.o_web_studio_export',
}, {
// click on the created app
trigger: '.o_app[data-menu-xmlid*="studio"]:last',
}, {
// switch to form view
trigger: '.o_web_studio_views_icons > a[title="Form"]',
}, {
extra_trigger: '.o_web_studio_form_view_editor',
// click on the view tab
trigger: '.o_web_studio_view',
}, {
// click on the restore default view button
trigger: '.o_web_studio_restore',
}, {
// click on the ok button
trigger: '.modal-footer .btn.btn-primary',
}, {
// checks that the field doesn't exist anymore
extra_trigger: 'label.o_form_label:not(:contains("COUCOU"))',
trigger: '.o_web_studio_leave > a.btn'
}]);
tour.register('web_studio_hide_fields_tour', {
url: "/web#action=studio&mode=home_menu",
test: true,
}, [{
trigger: '.o_web_studio_new_app',
}, {
trigger: '.o_web_studio_app_creator_next',
}, {
trigger: `
.o_web_studio_app_creator_name
> input`,
run: `text ${randomString(6)}`,
}, {
// make another interaction to show "next" button
trigger: `
.o_web_studio_selectors
.o_web_studio_selector:eq(2)`,
}, {
trigger: '.o_web_studio_app_creator_next',
}, {
trigger: `
.o_web_studio_app_creator_menu
> input`,
run: `text ${randomString(6)}`,
}, {
trigger: '.o_web_studio_app_creator_next',
}, {
trigger: '.o_web_studio_model_configurator_next',
}, {
// check that the Studio menu is still there
extra_trigger: '.o_web_studio_menu',
trigger: '.o_web_studio_leave > a.btn',
timeout: 60000, /* previous step reloads registry, etc. - could take a long time */
}, {
trigger: '.oe_title input',
run: 'text Test',
}, {
trigger: '.o_form_button_save',
}, {
trigger: '.o_web_studio_navbar_item',
}, {
extra_trigger: '.o_web_studio_menu',
trigger: `
.o_web_studio_views_icons
> a[title="List"]`,
}, {
// wait for the list editor to be rendered because the sidebar is the same
extra_trigger: '.o_web_studio_list_view_editor',
trigger: '.o_web_studio_existing_fields_icon',
}, {
trigger: `
.o_web_studio_sidebar
.o_web_studio_existing_fields
.o_web_studio_component:has(.o_web_studio_component_description:contains(display_name))`,
run: 'drag_and_drop .o_web_studio_list_view_editor .o_web_studio_hook',
}, {
trigger: `
.o_list_table
th[data-name="display_name"]`,
}, {
trigger: `
.o_web_studio_sidebar
select[name="optional"]`,
run: "text Hide by default",
}, {
extra_trigger: '.o_list_table:not(:has(th[data-name="display_name"]))',
trigger: `
.o_web_studio_sidebar_header
.o_web_studio_view`,
}, {
trigger: `
.o_web_studio_sidebar_checkbox
input#show_invisible`,
}, {
extra_trigger: `
.o_list_table
th[data-name="display_name"].o_web_studio_show_invisible`,
trigger: '.o_web_studio_leave > a.btn',
}]);
tour.register('web_studio_model_option_value_tour', {
url: "/web?debug=tests#action=studio&mode=home_menu",
test: true,
}, [{
trigger: '.o_web_studio_new_app',
}, {
trigger: '.o_web_studio_app_creator_next',
}, {
trigger: `
.o_web_studio_app_creator_name
> input`,
run: `text ${randomString(6)}`,
}, {
trigger: `
.o_web_studio_selectors
.o_web_studio_selector:eq(2)`,
}, {
trigger: '.o_web_studio_app_creator_next',
}, {
trigger: `
.o_web_studio_app_creator_menu
> input`,
run: `text ${randomString(6)}`,
}, {
trigger: '.o_web_studio_app_creator_next',
}, {
// check monetary value in model configurator
trigger: 'input[name="use_value"]',
}, {
// check lines value in model configurator
trigger: 'input[name="lines"]',
}, {
trigger: '.o_web_studio_model_configurator_next',
}, {
trigger: '.o_web_studio_menu .o_web_studio_views_icons > a[title="Graph"]',
timeout: 60000, /* previous step reloads registry, etc. - could take a long time */
}, {
// wait for the graph editor to be rendered and also check for sample data
extra_trigger: '.o_view_sample_data .o_graph_renderer',
trigger: '.o_web_studio_menu .o_web_studio_views_icons a[title="Pivot"]',
}, {
// wait for the pivot editor to be rendered and also check for sample data
extra_trigger: '.o_pivot_view .o_view_sample_data .o_view_nocontent_empty_folder',
trigger: '.o_web_studio_leave > a.btn',
}]);
tour.register('web_studio_new_report_tour', {
url: "/web",
test: true,
}, [{
// open studio
trigger: '.o_main_navbar .o_web_studio_navbar_item',
}, {
// click on the created app
trigger: '.o_app[data-menu-xmlid*="studio"]:first',
extra_trigger: 'body.o_in_studio',
}, {
// edit reports
trigger: '.o_web_studio_menu li a:contains(Reports)',
}, {
// create a new report
trigger: '.o_control_panel .o-kanban-button-new',
}, {
// select external layout
trigger: '.o_web_studio_report_layout_dialog div[data-layout="web.external_layout"]',
}, {
// sidebar should display add tab
extra_trigger: '.o_web_studio_report_editor_manager .o_web_studio_sidebar_header div.active[name="new"]',
// switch to 'Report' tab
trigger: '.o_web_studio_report_editor_manager .o_web_studio_sidebar_header div[name="report"]',
}, {
// edit report name
trigger: '.o_web_studio_sidebar input[name="name"]',
run: 'text My Awesome Report',
}, {
// switch to 'Add' in Sidebar
trigger: '.o_web_studio_sidebar div[name="new"]',
}, {
// wait for the iframe to be loaded
extra_trigger: '.o_web_studio_report_editor iframe #wrapwrap',
// add a 'title' building block
trigger: '.o_web_studio_sidebar .o_web_studio_component:contains(Title Block)',
run: 'drag_and_drop .o_web_studio_report_editor iframe .article > .page',
auto: true,
}, {
// click on the newly added field
trigger: '.o_web_studio_report_editor iframe .h2 > span:contains(New Title)',
}, {
// change the text of the H2 to 'test'
trigger: '.o_web_studio_sidebar .o_web_studio_text .note-editable',
run: function () {
this.$anchor.focusIn();
this.$anchor[0].firstChild.textContent = 'Test';
this.$anchor.keydown();
this.$anchor.blur();
}
}, {
// click outside to blur the field
trigger: '.o_web_studio_report_editor',
extra_trigger: '.o_web_studio_sidebar .o_web_studio_text .note-editable:contains(Test)',
}, {
extra_trigger: '.o_web_studio_report_editor iframe .h2:contains(Test)',
// add a new group on the node
trigger: '.o_web_studio_sidebar .o_field_many2manytags[name="groups"] input',
run: function () {
this.$anchor.click();
},
}, {
trigger: '.ui-autocomplete:visible li:contains(Access Rights)',
}, {
// wait for the group to appear
extra_trigger: '.o_web_studio_sidebar .o_field_many2manytags[name="groups"] .o_badge_text:contains(Access Rights)',
// switch to 'Add' in Sidebar
trigger: '.o_web_studio_sidebar div[name="new"]',
}, {
// add a 'title' building block Data Table
trigger: '.o_web_studio_sidebar .o_web_studio_component:contains(Data table)',
run: 'drag_and_drop .o_web_studio_report_editor iframe .article > .page',
}, {
// expand the model selector in the popup
trigger: 'div.o_field_selector_value',
run: function () {
$('div.o_field_selector_value').focusin();
}
}, {
// select the second element of the model (followers)
trigger: '.o_field_selector_popover_body > ul > li:contains(Followers)'
}, {
trigger:'.modal-content button>span:contains(Confirm)', // button
extra_trigger:'.o_field_selector_chain_part:contains(Followers)',//content of the field is set
}, {
// select the content of the first field of the newly added table
trigger: '.o_web_studio_report_editor iframe span[t-field="table_line.display_name"]'
}, {
// change the bound field
trigger: '.o_web_studio_sidebar .card:last() div.o_field_selector_value',
run: function () {
$('.o_web_studio_sidebar .card:last() div.o_field_selector_value').focusin();
}
}, {
trigger: 'ul.o_field_selector_page li:contains(ID)'
}, {
// update the title of the column
extra_trigger: '.o_web_studio_report_editor iframe span[t-field="table_line.id"]',
trigger: '.o_web_studio_report_editor iframe table thead span:contains(Name) ', // the name title
//extra_trigger: '.o_web_studio_report_editor iframe span[t-field="table_line.display_name"]:not(:contains(YourCompany, Administrator))', // the id has been updated in the iframe
}, {
// update column title 'name' into another title
trigger: '.o_web_studio_sidebar .o_web_studio_text .note-editable',
run: function () {
this.$anchor.focusIn();
this.$anchor[0].firstChild.textContent = 'new column title';
this.$anchor.keydown();
this.$anchor.blur();
}
}, {
// click outside to blur the field
trigger: '.o_web_studio_report_editor',
extra_trigger: '.o_web_studio_sidebar .o_web_studio_text .note-editable:contains(new column title)',
}, {
// wait to be sure the modification has been correctly applied
extra_trigger: '.o_web_studio_report_editor iframe table thead span:contains(new column title) ',
// leave the report
trigger: '.o_web_studio_breadcrumb .o_back_button:contains(Reports)',
}, {
// a invisible element cannot be used as a trigger so this small hack is
// mandatory for the next step
run: function () {
$('.o_kanban_record:contains(My Awesome Report) .dropdown-toggle').css('visibility', 'visible');
},
trigger: '.o_legacy_kanban_view',
}, {
// open the dropdown
trigger: '.o_kanban_record:contains(My Awesome Report) .dropdown-toggle',
}, {
// duplicate the report
trigger: '.o_kanban_record:contains(My Awesome Report) .dropdown-menu a:contains(Duplicate)',
}, {
// open the duplicate report
trigger: '.o_kanban_record:contains(My Awesome Report copy(1))',
}, {
// switch to 'Report' tab
trigger: '.o_web_studio_report_editor_manager .o_web_studio_sidebar_header div[name="report"]',
}, {
// wait for the duplicated report to be correctly loaded
extra_trigger: '.o_web_studio_sidebar input[name="name"][value="My Awesome Report copy(1)"]',
// leave Studio
trigger: '.o_web_studio_leave > a.btn',
}]);
tour.register('web_studio_new_report_basic_layout_tour', {
url: "/web",
test: true,
}, [{
// open studio
trigger: '.o_main_navbar .o_web_studio_navbar_item',
}, {
// click on the created app
trigger: '.o_app[data-menu-xmlid*="studio"]:first',
extra_trigger: 'body.o_in_studio',
}, {
// edit reports
trigger: '.o_web_studio_menu li a:contains(Reports)',
}, {
// create a new report
trigger: '.o_control_panel .o-kanban-button-new',
}, {
// select external layout
trigger: '.o_web_studio_report_layout_dialog div[data-layout="web.basic_layout"]',
}, {
// sidebar should display add tab
extra_trigger: '.o_web_studio_report_editor_manager .o_web_studio_sidebar_header div.active[name="new"]',
// switch to 'Report' tab
trigger: '.o_web_studio_report_editor_manager .o_web_studio_sidebar_header div[name="report"]',
}, {
// edit report name
trigger: '.o_web_studio_sidebar input[name="name"]',
run: 'text My Awesome basic layout Report',
}, {
// switch to 'Add' in Sidebar
trigger: '.o_web_studio_sidebar div[name="new"]',
}, {
// wait for the iframe to be loaded
extra_trigger: '.o_web_studio_report_editor iframe #wrapwrap',
// add a 'title' building block
trigger: '.o_web_studio_sidebar .o_web_studio_component:contains(Title Block)',
run: 'drag_and_drop .o_web_studio_report_editor iframe .article > .page',
auto: true,
}, {
// click on the newly added field
trigger: '.o_web_studio_report_editor iframe .h2 > span:contains(New Title)',
}, {
// change the text of the H2 to 'test'
trigger: '.o_web_studio_sidebar .o_web_studio_text .note-editable',
run: function () {
this.$anchor.focusIn();
this.$anchor[0].firstChild.textContent = 'Test';
this.$anchor.keydown();
this.$anchor.blur();
}
}, {
// click outside to blur the field
trigger: '.o_web_studio_report_editor',
extra_trigger: '.o_web_studio_sidebar .o_web_studio_text .note-editable:contains(Test)',
}, {
extra_trigger: '.o_web_studio_report_editor iframe .h2:contains(Test)',
// add a new group on the node
trigger: '.o_web_studio_sidebar .o_field_many2manytags[name="groups"] input',
run: function () {
this.$anchor.click();
},
}, {
trigger: '.ui-autocomplete:visible li:contains(Access Rights)',
}, {
// wait for the group to appear
extra_trigger: '.o_web_studio_sidebar .o_field_many2manytags[name="groups"] .o_badge_text:contains(Access Rights)',
// switch to 'Add' in Sidebar
trigger: '.o_web_studio_sidebar div[name="new"]',
}, {
// add a 'title' building block Data Table
trigger: '.o_web_studio_sidebar .o_web_studio_component:contains(Data table)',
run: 'drag_and_drop .o_web_studio_report_editor iframe .article > .page',
}, {
// expand the model selector in the popup
trigger: 'div.o_field_selector_value',
run: function () {
$('div.o_field_selector_value').focusin();
}
}, {
// select the second element of the model (followers)
trigger: '.o_field_selector_popover_body > ul > li:contains(Followers)'
}, {
trigger:'.modal-content button>span:contains(Confirm)', // button
extra_trigger:'.o_field_selector_chain_part:contains(Followers)', //content of the field is set
}, {
// select the content of the first field of the newly added table
trigger: '.o_web_studio_report_editor iframe span[t-field="table_line.display_name"]'
}, {
// change the bound field
trigger: '.o_web_studio_sidebar .card:last() div.o_field_selector_value',
run: function () {
$('.o_web_studio_sidebar .card:last() div.o_field_selector_value').focusin();
}
}, {
trigger: 'ul.o_field_selector_page li:contains(ID)'
}, {
// update the title of the column
trigger: '.o_web_studio_report_editor iframe table thead span:contains(Name) ', // the name title
//extra_trigger: '.o_web_studio_report_editor iframe span[t-field="table_line.display_name"]:not(:contains(YourCompany, Administrator))', // the id has been updated in the iframe
}, {
// update column title 'name' into another title
trigger: '.o_web_studio_sidebar .o_web_studio_text .note-editable',
run: function () {
this.$anchor.focusIn();
this.$anchor[0].firstChild.textContent = 'new column title';
this.$anchor.keydown();
this.$anchor.blur();
}
}, {
// click outside to blur the field
trigger: '.o_web_studio_report_editor',
extra_trigger: '.o_web_studio_sidebar .o_web_studio_text .note-editable:contains(new column title)',
}, {
// wait to be sure the modification has been correctly applied
extra_trigger: '.o_web_studio_report_editor iframe table thead span:contains(new column title) ',
// leave the report
trigger: '.o_web_studio_breadcrumb .o_back_button:contains(Reports)',
}, {
// a invisible element cannot be used as a trigger so this small hack is
// mandatory for the next step
run: function () {
$('.o_kanban_record:contains(My Awesome basic layout Report) .dropdown-toggle').css('visibility', 'visible');
},
trigger: '.o_legacy_kanban_view',
}, {
// open the dropdown
trigger: '.o_kanban_record:contains(My Awesome basic layout Report) .dropdown-toggle',
}, {
// duplicate the report
trigger: '.o_kanban_record:contains(My Awesome basic layout Report) .dropdown-menu a:contains(Duplicate)',
}, {
// open the duplicate report
trigger: '.o_kanban_record:contains(My Awesome basic layout Report copy(1))',
}, {
// switch to 'Report' tab
trigger: '.o_web_studio_report_editor_manager .o_web_studio_sidebar_header div[name="report"]',
}, {
// wait for the duplicated report to be correctly loaded
extra_trigger: '.o_web_studio_sidebar input[name="name"][value="My Awesome basic layout Report copy(1)"]',
// leave Studio
trigger: '.o_web_studio_leave > a.btn',
}]);
tour.register('web_studio_approval_tour', {
url: "/web",
test: true,
}, [{
// go to Apps menu
trigger: '.o_app[data-menu-xmlid="base.menu_management"]',
}, {
// open studio
trigger: '.o_main_navbar .o_web_studio_navbar_item',
extra_trigger: '.o_cp_switch_buttons',
}, {
// switch to form view editor
trigger: '.o_web_studio_views_icons > a[title="Form"]',
}, {
// click on first button it finds that has a node id
trigger: '.o_web_studio_form_view_editor button.o-web-studio-editor--element-clickable',
}, {
// enable approvals for the button
trigger: '.o_web_studio_sidebar label[for="studio_approval"]',
}, {
// set approval message
trigger: '.o_web_studio_sidebar_approval input[name*="approval_message"]',
run: 'text nope',
}, {
// add approval rule
trigger: '.o_web_studio_sidebar_approval .o_approval_new',
extra_trigger: '.o_web_studio_snackbar .fa-check',
}, {
// set domain on first rule
trigger: '.o_web_studio_sidebar_approval .o_approval_domain',
extra_trigger: '.o_studio_sidebar_approval_rule:eq(1)',
}, {
// set stupid domain that is always truthy
trigger: '.o_domain_debug_container textarea',
run: function () {
this.$anchor.focusIn();
this.$anchor.val('[["id", "!=", False]]');
this.$anchor.change();
}
}, {
// save domain and close modal
trigger:' .modal-footer .btn-primary',
}, {
// add second approval rule when the first is set
trigger: '.o_web_studio_sidebar_approval .o_approval_new',
extra_trigger: '.o_web_studio_snackbar .fa-check',
}, {
// enable 'force different users' for one rule (doesn't matter which)
trigger: '.o_web_studio_sidebar label[for*="exclusive_user"]',
extra_trigger: '.o_web_studio_snackbar .fa-check',
}, {
// leave studio
trigger: '.o_web_studio_leave > a.btn',
extra_trigger: '.o_web_studio_snackbar .fa-check',
}, {
// go back to kanban
trigger: '.o_menu_brand',
extra_trigger: '.o_web_client:not(.o_in_studio)'
}, {
// open first record (should be the one that was used, so the button should be there)
trigger: '.o_kanban_view .o_kanban_record .o_dropdown_kanban .dropdown-toggle',
}, {
trigger: '.o_kanban_view .o_kanban_record .o-dropdown--menu .dropdown-item',
},{
// try to do the action
trigger: 'button[studio_approval]',
}, {
// there should be a warning
trigger: '.o_notification.border-warning'
}, {
trigger: '.breadcrumb .o_back_button'
}]);
tour.register('web_studio_custom_field_tour', {
url: "/web",
test: true,
}, [{
// go to Apps menu
trigger: '.o_app[data-menu-xmlid="base.menu_management"]',
}, {
// click on the list view
trigger: '.o_switch_view.o_list',
}, {
// click on optional column dropdown
trigger: '.o_optional_columns_dropdown_toggle'
}, {
// click on add custom field
trigger: '.dropdown-item-studio'
}, {
// go to home menu
trigger: '.o_menu_toggle',
extra_trigger: '.o_web_client.o_in_studio'
}, {
//leave studio
trigger: '.o_web_studio_leave > a.btn'
}, {
// studio left.
trigger: '.o_app[data-menu-xmlid="base.menu_management"]',
extra_trigger: '.o_web_client:not(.o_in_studio)',
}]);
tour.register('web_studio_local_storage_tour', {
url: "/web",
test: true,
}, [{
trigger: '.o_app[data-menu-xmlid="base.menu_management"]',
run: function () {
localStorage.setItem('openStudioOnReload', "main");
window.location.reload();
},
}, {
// should be directly in studio mode
trigger: '.o_app[data-menu-xmlid="base.menu_management"]',
extra_trigger: '.o_web_client.o_in_studio'
}, {
trigger: '.o_menu_toggle',
}, {
trigger: '.o_web_studio_leave > a.btn',
}, {
// studio left.
trigger: '.o_app[data-menu-xmlid="base.menu_management"]',
extra_trigger: '.o_web_client:not(.o_in_studio)',
run: function () {
window.location.reload();
},
}, {
// studio left after refresh.
trigger: '.o_app[data-menu-xmlid="base.menu_management"]',
extra_trigger: '.o_web_client:not(.o_in_studio)'
}]);
});

View File

@@ -0,0 +1,271 @@
odoo.define('web_studio.form_tests', function (require) {
'use strict';
const FormView = require('web.FormView');
const testUtils = require('web.test_utils');
require('web_studio.FormRenderer');
require('web_studio.FormController');
const { legacyExtraNextTick } = require("@web/../tests/helpers/utils");
const createView = testUtils.createView;
QUnit.module(
'Studio',
{
beforeEach: function () {
this.data = {
partner: {
fields: {
display_name: { string: 'Displayed name', type: 'char' },
int_field: { string: 'int_field', type: 'integer', sortable: true },
bar: { string: 'Bar', type: 'boolean' },
},
records: [
{
id: 1,
display_name: 'first record',
int_field: 42,
bar: true,
},
{
id: 2,
display_name: 'second record',
int_field: 27,
bar: true,
},
],
},
};
},
},
function () {
QUnit.module('Form Approvals');
QUnit.test('approval widget basic rendering', async function (assert) {
assert.expect(14);
const form = await createView({
View: FormView,
model: 'partner',
data: this.data,
debug: true, // need to be in the viewport because of popover
arch: `<form string="Partners">
<sheet>
<header>
<button type="object=" name="someMethod" string="Apply Method" studio_approval="True"/>
</header>
<div name="button_box">
<button class="oe_stat_button" studio_approval="True" id="visibleStat">
<field name="int_field"/>
</button>
<button class="oe_stat_button" studio_approval="True"
attrs='{"invisible": [["bar", "=", true]]}' id="invisibleStat">
<field name="bar"/>
</button>
</div>
<group>
<group style="background-color: red">
<field name="display_name" studio_approval="True"/>
<field name="bar"/>
<field name="int_field"/>
</group>
</group>
<button type="object=" name="anotherMethod"
string="Apply Second Method" studio_approval="True"/>
</sheet>
</form>`,
res_id: 2,
mockRPC: function (route, args) {
if (args.method === 'get_approval_spec') {
assert.step('fetch_approval_spec');
return Promise.resolve({
rules: [
{
id: 1,
group_id: [1, 'Internal User'],
domain: false,
can_validate: true,
message: false,
exclusive_user: false,
},
],
entries: [],
groups: [[1, 'Internal User']],
});
}
return this._super(route, args);
},
session: { uid: 42 },
});
await legacyExtraNextTick(); // wait for the approval button (owl) to render
// check that the widget was inserted on visible buttons only
assert.containsOnce(form, 'button[name="someMethod"] .o_web_studio_approval');
assert.containsOnce(form, '#visibleStat .o_web_studio_approval');
assert.containsNone(form, '#invisibleStat .o_web_studio_approval');
assert.containsOnce(form, 'button[name="anotherMethod"] .o_web_studio_approval');
assert.containsNone(form, '.o_group .o_web_studio_approval');
// should have fetched spec for exactly 3 buttons
assert.verifySteps(['fetch_approval_spec', 'fetch_approval_spec', 'fetch_approval_spec']);
// display popover
await testUtils.dom.click('button[name="someMethod"] .o_web_studio_approval');
assert.containsOnce($(document), '.o_popover');
const popover = $(document).find('.o_popover');
assert.containsOnce(popover, '.o_web_studio_approval_no_entry');
assert.containsOnce(popover, '.o_web_approval_approve');
assert.containsOnce(popover, '.o_web_approval_reject');
assert.containsNone(popover, '.o_web_approval_cancel');
form.destroy();
});
QUnit.test('approval check', async function (assert) {
assert.expect(4);
const form = await createView({
View: FormView,
model: 'partner',
data: this.data,
arch: `<form string="Partners">
<sheet>
<header>
<button type="object" id="mainButton" name="someMethod"
string="Apply Method" studio_approval="True"/>
</header>
<group>
<group style="background-color: red">
<field name="display_name"/>
<field name="bar"/>
<field name="int_field"/>
</group>
</group>
</sheet>
</form>`,
res_id: 2,
mockRPC: function (route, args) {
const rule = {
id: 1,
group_id: [1, 'Internal User'],
domain: false,
can_validate: true,
message: false,
exclusive_user: false,
};
if (args.method === 'get_approval_spec') {
assert.step('fetch_approval_spec');
return Promise.resolve({
rules: [rule],
entries: [],
groups: [[1, 'Internal User']],
});
} else if (args.method === 'check_approval') {
assert.step('attempt_action');
return Promise.resolve({
approved: false,
rules: [rule],
entries: [],
});
} else if (args.method === 'someMethod') {
/* the action of the button should not be
called, as the approval is refused! if this
code is traversed, the test *must* fail!
that's why it's not included in the expected count
or in the verifySteps call */
assert.step('should_not_happen!');
}
return this._super(route, args);
},
});
await testUtils.dom.click('#mainButton');
// first render, handle click, rerender after click
assert.verifySteps(['fetch_approval_spec', 'attempt_action', 'fetch_approval_spec']);
form.destroy();
});
QUnit.test('approval widget basic flow', async function (assert) {
assert.expect(5);
let hasValidatedRule;
const form = await createView({
View: FormView,
model: 'partner',
data: this.data,
debug: true, // need to be in the viewport because of popover
arch: `<form string="Partners">
<sheet>
<header>
<button type="object=" name="someMethod" string="Apply Method" studio_approval="True"/>
</header>
<group>
<group style="background-color: red">
<field name="display_name"/>
<field name="bar"/>
<field name="int_field"/>
</group>
</group>
</sheet>
</form>`,
res_id: 2,
mockRPC: function (route, args) {
if (args.method === 'get_approval_spec') {
const spec = {
rules: [
{
id: 1,
group_id: [1, 'Internal User'],
domain: false,
can_validate: true,
message: false,
exclusive_user: false,
},
],
entries: [],
groups: [[1, 'Internal User']],
};
if (hasValidatedRule !== undefined) {
spec.entries = [
{
id: 1,
approved: hasValidatedRule,
user_id: [42, 'Some rando'],
write_date: '2020-04-07 12:43:48',
rule_id: [1, 'someMethod/partner (Internal User)'],
model: 'partner',
res_id: 2,
},
];
}
return Promise.resolve(spec);
} else if (args.method === 'set_approval') {
hasValidatedRule = args.kwargs.approved;
assert.step(hasValidatedRule ? 'approve_rule' : 'reject_rule');
return Promise.resolve(true);
} else if (args.method === 'delete_approval') {
hasValidatedRule = undefined;
assert.step('delete_approval');
return Promise.resolve(true);
}
return this._super(route, args);
},
session: { uid: 42 },
});
await legacyExtraNextTick(); // wait for the approval button (owl) to render
// display popover and validate a rule, then cancel, then reject
await testUtils.dom.click('button[name="someMethod"] .o_web_studio_approval');
assert.containsOnce($(document), '.o_popover');
await testUtils.dom.click('.o_popover button.o_web_approval_approve');
await testUtils.dom.click('.o_popover button.o_web_approval_cancel');
await testUtils.dom.click('.o_popover button.o_web_approval_reject');
assert.verifySteps(['approve_rule', 'delete_approval', 'reject_rule']);
form.destroy();
});
}
);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,97 @@
/** @odoo-module */
import { patch } from "@web/core/utils/patch";
import { MockServer } from "@web/../tests/helpers/mock_server";
patch(MockServer.prototype, "web_studio.MockServer", {
performRPC(route, args) {
if (route === "/web_studio/activity_allowed") {
return Promise.resolve(this.mockActivityAllowed());
}
if (route === "/web_studio/get_studio_view_arch") {
return Promise.resolve(this.mockGetStudioViewArch());
}
if (route === "/web_studio/chatter_allowed") {
return Promise.resolve(this.mockChatterAllowed());
}
if (route === "/web_studio/get_default_value") {
return Promise.resolve(this.mockGetDefaultValue());
}
if (route === "/web_studio/get_studio_action") {
return Promise.resolve(this.mockGetStudioAction(args));
}
if (route === "/web_studio/edit_view") {
return Promise.resolve(this.mockEditView(args));
}
return this._super(...arguments);
},
mockActivityAllowed() {
return false;
},
mockChatterAllowed() {
return false;
},
mockGetStudioViewArch() {
return {
studio_view_id: false,
studio_view_arch: "<data/>",
};
},
mockGetDefaultValue() {
return {};
},
mockGetStudioAction(args) {
if (args.action_name === "reports") {
return {
name: "Reports",
res_model: "ir.actions.report",
target: "current",
type: "ir.actions.act_window",
views: [[false, "kanban"]],
};
}
},
mockEditView(args) {
const viewId = args.view_id;
if (!viewId) {
throw new Error(
"To use the 'edit_view' mocked controller, you should specify a unique id on the view you are editing"
);
}
const uniqueViewKey = Object.keys(this.archs)
.map((k) => k.split(","))
.filter(([model, vid, vtype]) => vid === `${viewId}`);
if (!uniqueViewKey.length) {
throw new Error(`No view with id "${viewId}" in edit_view`);
}
if (uniqueViewKey.length > 1) {
throw new Error(
`There are multiple views with id "${viewId}", and probably for different models.`
);
}
const [modelName, , viewType] = uniqueViewKey[0];
const view = this.getView(modelName, [viewId, viewType], {
context: args.context,
options: {},
});
const models = {};
for (const modelName of Object.keys(view.models)) {
models[modelName] = this.mockFieldsGet(modelName);
}
return {
views: {
[viewType]: view,
},
models,
studio_view_id: false,
};
},
});

View File

@@ -0,0 +1,31 @@
/** @odoo-module **/
import { addFakeModel, addModelNamesToFetch } from '@bus/../tests/helpers/model_definitions_helpers';
addModelNamesToFetch(['res.groups', 'ir.ui.view']);
addFakeModel('product', {
partner_ids: { string: "Attachments", type: "one2many", relation: "partner" },
coucou_id: { string: "coucou", type: "many2one", relation: "coucou" },
display_name: { string: "Display Name", type: "char" },
m2m: { string: "M2M", type: "many2many", relation: "product" },
m2o: { string: "M2O", type: "many2one", relation: 'partner' },
related: { type: "char", related: "partner.display_name", string: "myRelatedField" },
sign: { string: "Signature", type: "binary" },
toughness: { manual: true, string: "toughness", type: 'selection', selection: [['0', "Hard"], ['1', "Harder"]] },
});
addFakeModel('coucou', {
display_name: { string: "Display Name", type: "char" },
char_field: { string: "A char", type: "char" },
croissant: { string: "Croissant", type: "integer" },
m2o: { string: "M2O", type: "many2one", relation: 'product' },
message_attachment_count: { string: 'Attachment count', type: 'integer' },
priority: { string: "Priority", type: "selection", manual: true, selection: [['1', "Low"], ['2', "Medium"], ['3', "High"]] },
product_ids: { string: "Products", type: "one2many", relation: "product" },
start: { string: "Start Date", type: 'datetime' },
stop: { string: "Stop Date", type: 'datetime' },
});
addFakeModel('partner', {
display_name: { string: "Display Name", type: "char" },
image: { string: "Image", type: "binary" },
displayed_image_id: { string: "cover", type: "many2one", relation: "ir.attachment" },
});

View File

@@ -0,0 +1,390 @@
/** @odoo-module **/
import { StudioNavbar } from "@web_studio/client_action/navbar/navbar";
import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
import { registerCleanup } from "@web/../tests/helpers/cleanup";
import {
click,
getFixture,
mount,
nextTick,
patchWithCleanup,
legacyExtraNextTick,
} from "@web/../tests/helpers/utils";
import { menuService } from "@web/webclient/menus/menu_service";
import { actionService } from "@web/webclient/actions/action_service";
import { makeFakeDialogService } from "@web/../tests/helpers/mock_services";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { registerStudioDependencies, openStudio, leaveStudio } from "./helpers";
import { createEnterpriseWebClient } from "@web_enterprise/../tests/helpers";
import { getActionManagerServerData, loadState } from "@web/../tests/webclient/helpers";
import { companyService } from "@web/webclient/company_service";
import { viewService } from "@web/views/view_service";
const serviceRegistry = registry.category("services");
let target;
QUnit.module("Studio > Navbar", (hooks) => {
let baseConfig;
hooks.beforeEach(() => {
target = getFixture();
registerStudioDependencies();
serviceRegistry.add("action", actionService).add("view", viewService); // #action-serv-leg-compat-js-class
serviceRegistry.add("dialog", makeFakeDialogService());
serviceRegistry.add("menu", menuService);
serviceRegistry.add("hotkey", hotkeyService);
patchWithCleanup(browser, {
setTimeout: (handler, delay, ...args) => handler(...args),
clearTimeout: () => {},
});
const menus = {
root: { id: "root", children: [1], name: "root", appID: "root" },
1: { id: 1, children: [10, 11, 12], name: "App0", appID: 1 },
10: { id: 10, children: [], name: "Section 10", appID: 1 },
11: { id: 11, children: [], name: "Section 11", appID: 1 },
12: { id: 12, children: [120, 121, 122], name: "Section 12", appID: 1 },
120: { id: 120, children: [], name: "Section 120", appID: 1 },
121: { id: 121, children: [], name: "Section 121", appID: 1 },
122: { id: 122, children: [], name: "Section 122", appID: 1 },
};
const serverData = { menus };
baseConfig = { serverData };
});
QUnit.test("menu buttons will not be placed under 'more' menu", async (assert) => {
assert.expect(12);
class MyStudioNavbar extends StudioNavbar {
async adapt() {
const prom = super.adapt();
const sectionsCount = this.currentAppSections.length;
const hiddenSectionsCount = this.currentAppSectionsExtra.length;
assert.step(`adapt -> hide ${hiddenSectionsCount}/${sectionsCount} sections`);
return prom;
}
}
const env = await makeTestEnv(baseConfig);
patchWithCleanup(env.services.studio, {
get mode() {
// Will force the the navbar in the studio editor state
return "editor";
},
});
// Force the parent width, to make this test independent of screen size
target.style.width = "100%";
// Set menu and mount
env.services.menu.setCurrentMenu(1);
await mount(MyStudioNavbar, target, { env });
await nextTick();
assert.containsN(
target,
".o_menu_sections > *:not(.o_menu_sections_more):not(.d-none)",
3,
"should have 3 menu sections displayed (that are not the 'more' menu)"
);
assert.containsNone(target, ".o_menu_sections_more", "the 'more' menu should not exist");
assert.containsN(
target,
".o-studio--menu > *",
2,
"should have 2 studio menu elements displayed"
);
assert.deepEqual(
[...target.querySelectorAll(".o-studio--menu > *")].map((el) => el.innerText),
["Edit Menu", "New Model"]
);
// Force minimal width and dispatch window resize event
target.style.width = "0%";
window.dispatchEvent(new Event("resize"));
await nextTick();
assert.containsOnce(
target,
".o_menu_sections > *:not(.d-none)",
"only one menu section should be displayed"
);
assert.containsOnce(
target,
".o_menu_sections_more:not(.d-none)",
"the displayed menu section should be the 'more' menu"
);
assert.containsN(
target,
".o-studio--menu > *",
2,
"should have 2 studio menu elements displayed"
);
assert.deepEqual(
[...target.querySelectorAll(".o-studio--menu > *")].map((el) => el.innerText),
["Edit Menu", "New Model"]
);
// Open the more menu
await click(target, ".o_menu_sections_more .dropdown-toggle");
assert.deepEqual(
[...target.querySelectorAll(".dropdown-menu > *")].map((el) => el.textContent),
["Section 10", "Section 11", "Section 12", "Section 120", "Section 121", "Section 122"],
"'more' menu should contain all hidden sections in correct order"
);
// Check the navbar adaptation calls
assert.verifySteps(["adapt -> hide 0/3 sections", "adapt -> hide 3/3 sections"]);
});
QUnit.test("homemenu customizer rendering", async (assert) => {
assert.expect(6);
serviceRegistry.add("company", companyService);
const fakeHTTPService = {
start() {
return {};
},
};
serviceRegistry.add("http", fakeHTTPService);
const env = await makeTestEnv(baseConfig);
patchWithCleanup(env.services.studio, {
get mode() {
// Will force the navbar in the studio home_menu state
return "home_menu";
},
});
const target = getFixture();
// Set menu and mount
await mount(StudioNavbar, target, { env });
await nextTick();
assert.containsOnce(target, ".o_studio_navbar");
assert.containsOnce(target, ".o_web_studio_home_studio_menu");
await click(target.querySelector(".o_web_studio_home_studio_menu .dropdown-toggle"));
assert.containsOnce(target, ".o_web_studio_change_background");
assert.strictEqual(
target.querySelector(".o_web_studio_change_background input").accept,
"image/*",
"Input should now only accept images"
);
assert.containsOnce(target, ".o_web_studio_import");
assert.containsOnce(target, ".o_web_studio_export");
});
});
QUnit.module("Studio > navbar coordination", (hooks) => {
let serverData;
let target;
hooks.beforeEach(() => {
target = getFixture();
serverData = getActionManagerServerData();
registerStudioDependencies();
serviceRegistry.add("company", companyService);
});
QUnit.test("adapt navbar when leaving studio", async (assert) => {
assert.expect(8);
patchWithCleanup(browser, {
setTimeout: (handler, delay, ...args) => handler(...args),
clearTimeout: () => {},
});
target.style.width = "1080px";
serverData.menus[1].actionID = 1;
serverData.actions[1].xml_id = "action_xml_id";
const webClient = await createEnterpriseWebClient({ serverData });
const width = document.body.style.width;
document.body.style.width = "1080px";
registerCleanup(() => {
document.body.style.width = width;
});
window.dispatchEvent(new Event("resize"));
await nextTick();
await nextTick();
await click(target.querySelector(".o_app[data-menu-xmlid=menu_1]"));
await legacyExtraNextTick();
await nextTick();
await nextTick();
assert.containsNone(target, ".o_menu_sections .o_menu_sections_more");
await openStudio(target);
await legacyExtraNextTick();
await nextTick();
await nextTick();
await nextTick();
assert.containsOnce(target, ".o_studio .o_menu_sections");
assert.containsNone(target, ".o_studio .o_menu_sections .o_menu_sections_more");
Object.assign(serverData.menus, {
10: {
id: 10,
children: [],
name: "The chain",
appID: 1,
actionID: 1001,
xmlid: "menu_1",
},
11: {
id: 11,
children: [111],
name: "Running in the shadows, damn your love, damn your lies",
appID: 1,
actionID: 1001,
xmlid: "menu_1",
},
12: {
id: 12,
children: [],
name: "You would never break the chain (Never break the chain)",
appID: 1,
actionID: 1001,
xmlid: "menu_1",
},
13: {
id: 13,
children: [],
name: "Chain keep us together (running in the shadow)",
appID: 1,
actionID: 1001,
xmlid: "menu_1",
},
111: {
id: 111,
children: [],
name: "You will never love me again",
appID: 1,
actionID: 1001,
xmlid: "menu_1",
},
});
serverData.menus[1].children = [10, 11, 12, 13];
await webClient.env.services.menu.reload();
// two ticks to allow the navbar to adapt
await nextTick();
await nextTick();
assert.strictEqual(
target.querySelectorAll(".o_studio header .o_menu_sections > *:not(.d-none)").length,
3
);
assert.containsOnce(target, ".o_studio .o_menu_sections .o_menu_sections_more");
await leaveStudio(target);
await legacyExtraNextTick();
// two more ticks to allow the navbar to adapt
await nextTick();
await nextTick();
assert.containsNone(target, ".o_studio");
assert.strictEqual(
target.querySelectorAll("header .o_menu_sections > *:not(.d-none)").length,
4
);
assert.containsOnce(target, ".o_menu_sections .o_menu_sections_more");
});
QUnit.test("adapt navbar when refreshing studio (loadState)", async (assert) => {
assert.expect(7);
target.style.width = "1080px";
const adapted = [];
patchWithCleanup(StudioNavbar.prototype, {
async adapt() {
const prom = this._super();
adapted.push(prom);
return prom;
},
});
serverData.menus[1].actionID = 1;
serverData.actions[1].xml_id = "action_xml_id";
serverData.actions[1].id = 1;
serverData.menus[1].children = [10, 11, 12, 13];
Object.assign(serverData.menus, {
10: {
id: 10,
children: [],
name: "The chain",
appID: 1,
actionID: 1001,
xmlid: "menu_1",
},
11: {
id: 11,
children: [111],
name: "Running in the shadows, damn your love, damn your lies",
appID: 1,
actionID: 1001,
xmlid: "menu_1",
},
12: {
id: 12,
children: [],
name: "You would never break the chain (Never break the chain)",
appID: 1,
actionID: 1001,
xmlid: "menu_1",
},
13: {
id: 13,
children: [],
name: "Chain keep us together (running in the shadow)",
appID: 1,
actionID: 1001,
xmlid: "menu_1",
},
111: {
id: 111,
children: [],
name: "You will never love me again",
appID: 1,
actionID: 1001,
xmlid: "menu_1",
},
});
const webClient = await createEnterpriseWebClient({ serverData });
window.dispatchEvent(new Event("resize"));
await Promise.all(adapted);
await click(target.querySelector(".o_app[data-menu-xmlid=menu_1]"));
await legacyExtraNextTick();
await Promise.all(adapted);
await nextTick();
await nextTick();
assert.containsNone(target, ".o_studio");
assert.strictEqual(
target.querySelectorAll("header .o_menu_sections > *:not(.d-none)").length,
4
);
assert.containsOnce(target, ".o_menu_sections .o_menu_sections_more");
await openStudio(target);
await Promise.all(adapted);
assert.strictEqual(
target.querySelectorAll(".o_studio header .o_menu_sections > *:not(.d-none)").length,
3
);
assert.containsOnce(target, ".o_studio .o_menu_sections .o_menu_sections_more");
const state = webClient.env.services.router.current.hash;
await loadState(webClient, state);
await Promise.all(adapted);
assert.strictEqual(
target.querySelectorAll(".o_studio header .o_menu_sections > *:not(.d-none)").length,
3
);
assert.containsOnce(target, ".o_studio .o_menu_sections .o_menu_sections_more");
});
});

View File

@@ -0,0 +1,628 @@
/** @odoo-module **/
import {
click,
getFixture,
legacyExtraNextTick,
makeDeferred,
nextTick,
patchWithCleanup,
} from "@web/../tests/helpers/utils";
import { companyService } from "@web/webclient/company_service";
import { createEnterpriseWebClient } from "@web_enterprise/../tests/helpers";
import { getActionManagerServerData } from "@web/../tests/webclient/helpers";
import { leaveStudio, openStudio, registerStudioDependencies } from "@web_studio/../tests/helpers";
import { registry } from "@web/core/registry";
import { session } from "@web/session";
import { patch, unpatch } from "@web/core/utils/patch";
import { StudioView } from "@web_studio/client_action/studio_view";
// -----------------------------------------------------------------------------
// Tests
// -----------------------------------------------------------------------------
let serverData;
let target;
QUnit.module("Studio", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = getActionManagerServerData();
registerStudioDependencies();
const serviceRegistry = registry.category("services");
serviceRegistry.add("company", companyService);
// tweak a bit the default config to better fit with studio needs:
// - add some menu items we can click on to test the navigation
// - add a one2many field in a form view to test the one2many edition
serverData.menus = {
root: { id: "root", children: [1, 2, 3], name: "root", appID: "root" },
1: {
id: 1,
children: [11, 12],
name: "Partners",
appID: 1,
actionID: 4,
xmlid: "app_1",
},
11: {
id: 11,
children: [],
name: "Partners (Action 4)",
appID: 1,
actionID: 4,
xmlid: "menu_11",
},
12: {
id: 12,
children: [],
name: "Partners (Action 3)",
appID: 1,
actionID: 3,
xmlid: "menu_12",
},
2: {
id: 2,
children: [],
name: "Ponies",
appID: 2,
actionID: 8,
xmlid: "app_2",
},
3: {
id: 3,
children: [],
name: "Client Action",
appID: 3,
actionID: 9,
xmlid: "app_3",
},
};
serverData.models.partner.fields.date = { string: "Date", type: "date" };
serverData.models.partner.fields.pony_id = {
string: "Pony",
type: "many2one",
relation: "pony",
};
serverData.models.pony.fields.partner_ids = {
string: "Partners",
type: "one2many",
relation: "partner",
relation_field: "pony_id",
};
serverData.views["pony,false,form"] = `
<form>
<field name="name"/>
<field name='partner_ids'>
<form>
<sheet>
<field name='display_name'/>
</sheet>
</form>
</field>
</form>`;
});
QUnit.module("Studio Navigation");
QUnit.test("Studio not available for non system users", async function (assert) {
assert.expect(2);
patchWithCleanup(session, { is_system: false });
await createEnterpriseWebClient({ serverData });
assert.containsOnce(target, ".o_main_navbar");
assert.containsNone(target, ".o_main_navbar .o_web_studio_navbar_item a");
});
QUnit.test("open Studio with act_window", async function (assert) {
assert.expect(22);
const mockRPC = async (route) => {
assert.step(route);
};
await createEnterpriseWebClient({ serverData, mockRPC });
assert.containsOnce(target, ".o_home_menu");
// open app Partners (act window action)
await click(target.querySelector(".o_app[data-menu-xmlid=app_1]"));
await legacyExtraNextTick();
assert.containsOnce(target, ".o_kanban_view");
assert.verifySteps(
[
"/web/webclient/load_menus",
"/web/action/load",
"/web/dataset/call_kw/partner/get_views",
"/web/dataset/call_kw/partner/web_search_read",
],
"should have loaded the action"
);
assert.containsOnce(target, ".o_main_navbar .o_web_studio_navbar_item a");
await openStudio(target);
assert.verifySteps(
[
"/web_studio/activity_allowed",
"/web_studio/get_studio_view_arch",
"/web/dataset/call_kw/partner/get_views",
"/web/dataset/search_read",
],
"should have opened the action in Studio"
);
assert.containsOnce(
target,
".o_web_studio_client_action .o_web_studio_kanban_view_editor",
"the kanban view should be opened"
);
assert.containsOnce(
target,
".o_kanban_record:contains(yop)",
"the first partner should be displayed"
);
assert.containsOnce(target, ".o_studio_navbar .o_web_studio_leave a");
await leaveStudio(target);
assert.verifySteps(
[
"/web/action/load",
"/web/dataset/call_kw/partner/get_views",
"/web/dataset/call_kw/partner/web_search_read",
],
"should have reloaded the previous action edited by Studio"
);
assert.containsNone(target, ".o_web_studio_client_action", "Studio should be closed");
assert.containsOnce(
target,
".o_kanban_view .o_kanban_record:contains(yop)",
"the first partner should be displayed in kanban"
);
});
QUnit.test("open Studio with act_window and viewType", async function (assert) {
await createEnterpriseWebClient({ serverData });
// open app Partners (act window action), sub menu Partners (action 3)
await click(target.querySelector(".o_app[data-menu-xmlid=app_1]"));
// the menu is rendered once the action is ready, so potentially in the next animation frame
await nextTick();
await click(target, ".o_menu_sections .o_nav_entry:nth-child(2)");
assert.containsOnce(target, ".o_list_view");
await click(target.querySelector(".o_data_row .o_data_cell")); // open a record
assert.containsOnce(target, ".o_form_view");
await openStudio(target);
assert.containsOnce(
target,
".o_web_studio_client_action .o_web_studio_form_view_editor",
"the form view should be opened"
);
assert.strictEqual(
$(target).find('.o_field_widget[name="foo"]').text(),
"yop",
"the first partner should be displayed"
);
});
QUnit.test("reload the studio view", async function (assert) {
assert.expect(5);
const webClient = await createEnterpriseWebClient({ serverData });
// open app Partners (act window action), sub menu Partners (action 3)
await click(target.querySelector(".o_app[data-menu-xmlid=app_1]"));
await legacyExtraNextTick();
assert.strictEqual(
$(target).find(".o_kanban_record:contains(yop)").length,
1,
"the first partner should be displayed"
);
await click(target.querySelector(".o_kanban_record")); // open a record
await legacyExtraNextTick();
assert.containsOnce(target, ".o_form_view");
assert.strictEqual(
$(target).find(".o_form_view input:propValue(yop)").length,
1,
"should have open the same record"
);
let prom = makeDeferred();
patch(StudioView.prototype, "web_studio.Test.StudioView", {
setup() {
this._super();
owl.onMounted(() => {
prom.resolve();
});
},
});
await openStudio(target);
await prom;
prom = makeDeferred();
await webClient.env.services.studio.reload();
await prom;
unpatch(StudioView.prototype, "web_studio.Test.StudioView");
assert.containsOnce(
target,
".o_web_studio_client_action .o_web_studio_form_view_editor",
"the studio view should be opened after reloading"
);
assert.strictEqual(
$(target).find(".o_form_view span:contains(yop)").length,
1,
"should have open the same record"
);
});
QUnit.test("switch view and close Studio", async function (assert) {
assert.expect(6);
await createEnterpriseWebClient({ serverData });
// open app Partners (act window action)
await click(target.querySelector(".o_app[data-menu-xmlid=app_1]"));
await legacyExtraNextTick();
assert.containsOnce(target, ".o_kanban_view");
await openStudio(target);
assert.containsOnce(target, ".o_web_studio_client_action .o_web_studio_kanban_view_editor");
// click on tab "Views"
await click(target.querySelector(".o_web_studio_menu .o_web_studio_menu_item a"));
assert.containsOnce(target, ".o_web_studio_action_editor");
// open list view
await click(
target.querySelector(
".o_web_studio_views .o_web_studio_view_type[data-type=list] .o_web_studio_thumbnail"
)
);
assert.containsOnce(target, ".o_web_studio_client_action .o_web_studio_list_view_editor");
await leaveStudio(target);
assert.containsNone(target, ".o_web_studio_client_action", "Studio should be closed");
assert.containsOnce(target, ".o_list_view", "the list view should be opened");
});
QUnit.test("navigation in Studio with act_window", async function (assert) {
assert.expect(28);
const mockRPC = async (route) => {
assert.step(route);
};
await createEnterpriseWebClient({ serverData, mockRPC });
// open app Partners (act window action)
await click(target.querySelector(".o_app[data-menu-xmlid=app_1]"));
await legacyExtraNextTick();
assert.verifySteps(
[
"/web/webclient/load_menus",
"/web/action/load",
"/web/dataset/call_kw/partner/get_views",
"/web/dataset/call_kw/partner/web_search_read",
],
"should have loaded the action"
);
await openStudio(target);
assert.verifySteps(
[
"/web_studio/activity_allowed",
"/web_studio/get_studio_view_arch",
"/web/dataset/call_kw/partner/get_views",
"/web/dataset/search_read",
],
"should have opened the action in Studio"
);
assert.containsOnce(
target,
".o_web_studio_client_action .o_web_studio_kanban_view_editor",
"the kanban view should be opened"
);
assert.strictEqual(
$(target).find(".o_kanban_record:contains(yop)").length,
1,
"the first partner should be displayed"
);
await click(target.querySelector(".o_studio_navbar .o_menu_toggle"));
assert.containsOnce(target, ".o_studio_home_menu");
// open app Ponies (act window action)
await click(target.querySelector(".o_app[data-menu-xmlid=app_2]"));
await legacyExtraNextTick();
assert.verifySteps(
[
"/web/action/load",
"/web_studio/activity_allowed",
"/web_studio/get_studio_view_arch",
"/web/dataset/call_kw/pony/get_views",
"/web/dataset/search_read",
],
"should have opened the navigated action in Studio"
);
assert.containsOnce(
target,
".o_web_studio_client_action .o_web_studio_list_view_editor",
"the list view should be opened"
);
assert.strictEqual(
$(target).find(".o_legacy_list_view .o_data_cell").text(),
"Twilight SparkleApplejackFluttershy",
"the list of ponies should be correctly displayed"
);
await leaveStudio(target);
assert.verifySteps(
[
"/web/action/load",
"/web/dataset/call_kw/pony/get_views",
"/web/dataset/call_kw/pony/web_search_read",
],
"should have reloaded the previous action edited by Studio"
);
assert.containsNone(target, ".o_web_studio_client_action", "Studio should be closed");
assert.containsOnce(target, ".o_list_view", "the list view should be opened");
assert.strictEqual(
$(target).find(".o_list_view .o_data_cell").text(),
"Twilight SparkleApplejackFluttershy",
"the list of ponies should be correctly displayed"
);
});
QUnit.test("keep action context when leaving Studio", async function (assert) {
assert.expect(5);
let nbLoadAction = 0;
const mockRPC = async (route, args) => {
if (route === "/web/action/load") {
nbLoadAction++;
if (nbLoadAction === 2) {
assert.strictEqual(
args.additional_context.active_id,
1,
"the context should be correctly passed when leaving Studio"
);
}
}
};
serverData.actions[4].context = "{'active_id': 1}";
await createEnterpriseWebClient({
serverData,
mockRPC,
});
// open app Partners (act window action)
await click(target.querySelector(".o_app[data-menu-xmlid=app_1]"));
await legacyExtraNextTick();
assert.containsOnce(target, ".o_kanban_view");
await openStudio(target);
assert.containsOnce(target, ".o_web_studio_kanban_view_editor");
await leaveStudio(target);
assert.containsOnce(target, ".o_kanban_view");
assert.strictEqual(nbLoadAction, 2, "the action should have been loaded twice");
});
QUnit.test("open same record when leaving form", async function (assert) {
await createEnterpriseWebClient({ serverData });
// open app Ponies (act window action)
await click(target.querySelector(".o_app[data-menu-xmlid=app_2]"));
assert.containsOnce(target, ".o_list_view");
await click(target.querySelector(".o_data_row .o_data_cell"));
assert.containsOnce(target, ".o_form_view");
await openStudio(target);
assert.containsOnce(target, ".o_web_studio_client_action .o_web_studio_form_view_editor");
await leaveStudio(target);
assert.containsOnce(target, ".o_form_view");
assert.containsOnce(target, ".o_form_view .o_field_widget[name=name] input");
assert.strictEqual(
target.querySelector(".o_form_view .o_field_widget[name=name] input").value,
"Twilight Sparkle"
);
});
QUnit.test("open Studio with non editable view", async function (assert) {
assert.expect(2);
serverData.menus[99] = {
id: 9,
children: [],
name: "Action with Grid view",
appID: 9,
actionID: 99,
xmlid: "app_9",
};
serverData.menus.root.children.push(99);
serverData.actions[99] = {
id: 99,
xml_id: "some.xml_id",
name: "Partners Action 99",
res_model: "partner",
type: "ir.actions.act_window",
views: [
[42, "grid"],
[2, "list"],
[false, "form"],
],
};
serverData.views["partner,42,grid"] = `
<grid>
<field name="foo" type="row"/>
<field name="id" type="measure"/>
<field name="date" type="col">
<range name="week" string="Week" span="week" step="day"/>
</field>
</grid>`;
await createEnterpriseWebClient({
serverData,
legacyParams: { withLegacyMockServer: true },
});
await click(target.querySelector(".o_app[data-menu-xmlid=app_9]"));
await legacyExtraNextTick();
assert.containsOnce(target, ".o_grid_view");
await openStudio(target);
assert.containsOnce(
target,
".o_web_studio_action_editor",
"action editor should be opened (grid is not editable)"
);
});
QUnit.test(
"open list view with sample data gives empty list view in studio",
async function (assert) {
assert.expect(2);
serverData.models.pony.records = [];
serverData.views["pony,false,list"] = `<tree sample="1"><field name="name"/></tree>`;
await createEnterpriseWebClient({
serverData,
});
// open app Ponies (act window action)
await click(target.querySelector(".o_app[data-menu-xmlid=app_2]"));
await legacyExtraNextTick();
assert.ok(
[...target.querySelectorAll(".o_list_table .o_data_row")].length > 0,
"there should be some sample data in the list view"
);
await openStudio(target);
assert.containsNone(
target,
".o_list_table .o_data_row",
"the list view should not contain any data"
);
}
);
QUnit.test(
"open Studio with editable form view and check context propagation",
async function (assert) {
assert.expect(6);
serverData.menus[43] = {
id: 43,
children: [],
name: "Form with context",
appID: 43,
actionID: 43,
xmlid: "app_43",
};
serverData.menus.root.children.push(43);
serverData.actions[43] = {
id: 43,
name: "Pony Action 43",
res_model: "pony",
type: "ir.actions.act_window",
views: [[false, "form"]],
context: "{'default_type': 'foo'}",
res_id: 4,
xml_id: "action_43",
};
const mockRPC = async (route, args) => {
if (route === "/web/dataset/call_kw/pony/read") {
// We pass here twice: once for the "classic" action
// and once when entering studio
assert.strictEqual(args.kwargs.context.default_type, "foo");
}
if (route === "/web/dataset/call_kw/partner/onchange") {
assert.ok(
!("default_type" in args.kwargs.context),
"'default_x' context value should not be propaged to x2m model"
);
}
};
await createEnterpriseWebClient({
serverData,
mockRPC,
});
await click(target.querySelector(".o_app[data-menu-xmlid=app_43]"));
await legacyExtraNextTick();
assert.containsOnce(target, ".o_form_view");
await openStudio(target);
assert.containsOnce(
target,
".o_web_studio_client_action .o_web_studio_form_view_editor",
"the form view should be opened"
);
await click(target.querySelector(".o_web_studio_form_view_editor .o_field_one2many"));
await click(
target.querySelector(
'.o_web_studio_form_view_editor .o_field_one2many .o_web_studio_editX2Many[data-type="form"]'
)
);
assert.containsOnce(
target,
".o_web_studio_client_action .o_web_studio_form_view_editor",
"the form view should be opened"
);
}
);
QUnit.test(
"concurrency: execute a non editable action and try to enter studio",
async function (assert) {
// the purpose of this test is to ensure that there's no time window
// during which if the icon isn't disabled, but the current action isn't
// editable (typically, just after the current action has changed).
assert.expect(5);
const def = makeDeferred();
serverData.actions[4].xml_id = false; // make action 4 non editable
const webClient = await createEnterpriseWebClient({ serverData });
assert.containsOnce(target, ".o_home_menu");
webClient.env.bus.on("ACTION_MANAGER:UI-UPDATED", null, () => {
assert.containsOnce(target, ".o_kanban_view");
assert.hasClass(target.querySelector(".o_web_studio_navbar_item"), "o_disabled");
def.resolve();
});
// open app Partners (non editable act window action)
await click(target.querySelector(".o_app[data-menu-xmlid=app_1]"));
await def;
assert.containsOnce(target, ".o_kanban_view");
assert.hasClass(target.querySelector(".o_web_studio_navbar_item"), "o_disabled");
}
);
});

View File

@@ -0,0 +1,339 @@
/** @odoo-module **/
import { IconCreator } from "@web_studio/client_action/icon_creator/icon_creator";
import { StudioHomeMenu } from "@web_studio/client_action/studio_home_menu/studio_home_menu";
import { MODES } from "@web_studio/studio_service";
import { ormService } from "@web/core/orm_service";
import { enterpriseSubscriptionService } from "@web_enterprise/webclient/home_menu/enterprise_subscription_service";
import {
fakeCommandService,
makeFakeNotificationService,
makeFakeRPCService,
} from "@web/../tests/helpers/mock_services";
import { userService } from "@web/core/user_service";
import { uiService } from "@web/core/ui/ui_service";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { registerCleanup } from "@web/../tests/helpers/cleanup";
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
import { click, getFixture, mount } from "@web/../tests/helpers/utils";
import { dialogService } from "@web/core/dialog/dialog_service";
import { registry } from "@web/core/registry";
const { Component, EventBus, xml } = owl;
const serviceRegistry = registry.category("services");
const genericHomeMenuProps = {
apps: [
{
actionID: 121,
id: 1,
appID: 1,
label: "Discuss",
parents: "",
webIcon: "mail,static/description/icon.png",
webIconData: "/web_enterprise/static/img/default_icon_app.png",
xmlid: "app.1",
},
{
actionID: 122,
id: 2,
appID: 2,
label: "Calendar",
parents: "",
webIcon: {
backgroundColor: "#C6572A",
color: "#FFFFFF",
iconClass: "fa fa-diamond",
},
xmlid: "app.2",
},
{
actionID: 123,
id: 3,
appID: 3,
label: "Contacts",
parents: "",
webIcon: false,
webIconData: "/web_enterprise/static/img/default_icon_app.png",
xmlid: "app.3",
},
],
};
// -----------------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------------
const createStudioHomeMenu = async () => {
class Parent extends Component {
get DialogContainer() {
return registry.category("main_components").get("DialogContainer");
}
}
Parent.components = { StudioHomeMenu };
Parent.template = xml`
<div>
<StudioHomeMenu t-props="props.homeMenuProps" />
<div class="o_dialog_container" />
<t t-component="DialogContainer.Component" t-props="DialogContainer.props" />
</div>`;
const env = await makeTestEnv();
const target = getFixture();
await mount(Parent, target, { env, props: { homeMenuProps: { ...genericHomeMenuProps } } });
return target;
};
// -----------------------------------------------------------------------------
// Tests
// -----------------------------------------------------------------------------
let bus;
QUnit.module("Studio", (hooks) => {
hooks.beforeEach(() => {
IconCreator.enableTransitions = false;
registerCleanup(() => {
IconCreator.enableTransitions = true;
});
bus = new EventBus();
const fakeNotificationService = makeFakeNotificationService();
const fakeHomeMenuService = {
start() {
return {
toggle() {},
};
},
};
const fakeMenuService = {
start() {
return {
setCurrentMenu(menu) {
bus.trigger("menu:setCurrentMenu", menu.id);
},
reload() {
bus.trigger("menu:reload");
},
getMenu() {
return {};
},
};
},
};
const fakeStudioService = {
start() {
return {
MODES,
open(...args) {
bus.trigger("studio:open", args);
},
};
},
};
const fakeHTTPService = {
start() {
return {};
},
};
serviceRegistry.add("orm", ormService);
serviceRegistry.add("enterprise_subscription", enterpriseSubscriptionService);
serviceRegistry.add("home_menu", fakeHomeMenuService);
serviceRegistry.add("http", fakeHTTPService);
serviceRegistry.add("menu", fakeMenuService);
serviceRegistry.add("notification", fakeNotificationService);
serviceRegistry.add("user", userService);
serviceRegistry.add("studio", fakeStudioService);
serviceRegistry.add("hotkey", hotkeyService);
serviceRegistry.add("dialog", dialogService);
serviceRegistry.add("ui", uiService);
serviceRegistry.add("command", fakeCommandService);
});
QUnit.module("StudioHomeMenu");
QUnit.test("simple rendering", async (assert) => {
assert.expect(20);
const target = await createStudioHomeMenu();
// Main div
assert.containsOnce(target, ".o_home_menu");
// Hidden elements
assert.isNotVisible(
target.querySelector(".database_expiration_panel"),
"Expiration panel should not be visible"
);
// App list
assert.containsOnce(target, "div.o_apps");
assert.containsN(
target,
"div.o_apps > a.o_app.o_menuitem",
4,
"should contain 3 normal app icons + the new app button"
);
// App with image
const firstApp = target.querySelector("div.o_apps > a.o_app.o_menuitem");
assert.strictEqual(firstApp.dataset.menuXmlid, "app.1");
assert.containsOnce(firstApp, "img.o_app_icon");
assert.strictEqual(
firstApp.querySelector("img.o_app_icon").dataset.src,
"/web_enterprise/static/img/default_icon_app.png"
);
assert.containsOnce(firstApp, "div.o_caption");
assert.strictEqual(firstApp.querySelector("div.o_caption").innerText, "Discuss");
assert.containsOnce(firstApp, ".o_web_studio_edit_icon i");
// App with custom icon
const secondApp = target.querySelectorAll("div.o_apps > a.o_app.o_menuitem")[1];
assert.strictEqual(secondApp.dataset.menuXmlid, "app.2");
assert.containsOnce(secondApp, "div.o_app_icon");
assert.strictEqual(
secondApp.querySelector("div.o_app_icon").style.backgroundColor,
"rgb(198, 87, 42)",
"Icon background color should be #C6572A"
);
assert.containsOnce(secondApp, "i.fa.fa-diamond");
assert.strictEqual(
secondApp.querySelector("i.fa.fa-diamond").style.color,
"rgb(255, 255, 255)",
"Icon color should be #FFFFFF"
);
assert.containsOnce(secondApp, ".o_web_studio_edit_icon i");
// New app button
assert.containsOnce(
target,
"div.o_apps > a.o_app.o_web_studio_new_app",
'should contain a "New App icon"'
);
const newApp = target.querySelector("a.o_app.o_web_studio_new_app");
assert.strictEqual(
newApp.querySelector("img.o_app_icon").dataset.src,
"/web_studio/static/src/img/default_icon_app.png",
"Image source URL should end with '/web_studio/static/src/img/default_icon_app.png'"
);
assert.containsOnce(newApp, "div.o_caption");
assert.strictEqual(newApp.querySelector("div.o_caption").innerText, "New App");
});
QUnit.test("Click on a normal App", async (assert) => {
assert.expect(2);
bus.on("studio:open", null, (modeAndActionId) => {
assert.deepEqual(modeAndActionId, [MODES.EDITOR, 121]);
});
bus.on("menu:setCurrentMenu", null, (menuId) => {
assert.strictEqual(menuId, 1);
});
const target = await createStudioHomeMenu();
await click(target.querySelector(".o_menuitem"));
});
QUnit.test("Click on new App", async (assert) => {
assert.expect(1);
bus.on("studio:open", null, ([mode]) => {
assert.strictEqual(mode, MODES.APP_CREATOR);
});
bus.on("menu:setCurrentMenu", null, () => {
throw new Error("should not update the current menu");
});
const target = await createStudioHomeMenu();
await click(target, "a.o_app.o_web_studio_new_app");
});
QUnit.test("Click on edit icon button", async (assert) => {
assert.expect(11);
const target = await createStudioHomeMenu();
// TODO: we should maybe check icon visibility comes on mouse over
const firstEditIconButton = target.querySelector(".o_web_studio_edit_icon i");
await click(firstEditIconButton);
const dialog = document.querySelector("div.modal");
assert.containsOnce(dialog, "header.modal-header");
assert.strictEqual(
dialog.querySelector("header.modal-header h4").innerText,
"Edit Application Icon"
);
assert.containsOnce(
dialog,
".modal-content.o_web_studio_edit_menu_icon_modal .o_web_studio_icon_creator"
);
assert.containsOnce(dialog, "footer.modal-footer");
assert.containsN(dialog, "footer button", 2);
const buttons = dialog.querySelectorAll("footer button");
const firstButton = buttons[0];
const secondButton = buttons[1];
assert.strictEqual(firstButton.innerText, "CONFIRM");
assert.hasClass(firstButton, "btn-primary");
assert.strictEqual(secondButton.innerText, "CANCEL");
assert.hasClass(secondButton, "btn-secondary");
await click(secondButton);
assert.strictEqual(document.querySelector("div.modal"), null);
await click(firstEditIconButton);
await click(document.querySelector("footer button"));
assert.strictEqual(document.querySelector("div.modal"), null);
});
QUnit.test("edit an icon", async (assert) => {
assert.expect(3);
const mockRPC = (route, args) => {
if (route === "/web_studio/edit_menu_icon") {
assert.deepEqual(args, {
context: {
lang: "en",
tz: "taht",
uid: 7,
},
icon: ["fa fa-balance-scale", "#f1c40f", "#34495e"],
menu_id: 1,
});
}
};
registry.category("services").add("rpc", makeFakeRPCService(mockRPC), { force: true });
const target = await createStudioHomeMenu();
await click(target.querySelector(".o_web_studio_edit_icon i"));
const dialog = document.querySelector("div.modal");
await click(dialog.querySelector(".o_web_studio_upload a"));
assert.doesNotHaveClass(
dialog.querySelector(".o_web_studio_icon .o_app_icon i"),
"fa-balance-scale"
);
// Change the icon's pictogram
await click(dialog.querySelectorAll(".o_web_studio_selector")[2]);
await click(dialog, ".o_web_studio_selector .fa.fa-balance-scale");
assert.hasClass(
dialog.querySelector(".o_web_studio_icon .o_app_icon i"),
"fa-balance-scale"
);
await click(dialog.querySelector("footer button")); // trigger save
});
});

View File

@@ -0,0 +1,8 @@
/** @odoo-module */
import { unpatch } from "@web/core/utils/patch";
import { ListRenderer } from "@web/views/list/list_renderer";
import "@web_studio/views/list/list_renderer";
unpatch(ListRenderer.prototype, "web_studio.ListRenderer");

View File

@@ -0,0 +1,80 @@
/** @odoo-module **/
import { click, getFixture, patchWithCleanup } from "@web/../tests/helpers/utils";
import { doAction, getActionManagerServerData } from "@web/../tests/webclient/helpers";
import { patch, unpatch } from "@web/core/utils/patch";
import { session } from "@web/session";
import { ListRenderer } from "@web/views/list/list_renderer";
import { createEnterpriseWebClient } from "@web_enterprise/../tests/helpers";
import { patchListRendererDesktop } from "@web_enterprise/views/list/list_renderer_desktop";
import { registerStudioDependencies } from "@web_studio/../tests/helpers";
import { patchListRendererStudio } from "@web_studio/views/list/list_renderer";
let serverData;
let target;
QUnit.module("Studio", (hooks) => {
hooks.beforeEach(() => {
serverData = getActionManagerServerData();
registerStudioDependencies();
patchWithCleanup(session, { is_system: true });
target = getFixture();
patch(
ListRenderer.prototype,
"web_enterprise.ListRendererDesktop",
patchListRendererDesktop
);
patch(ListRenderer.prototype, "web_studio.ListRenderer", patchListRendererStudio);
});
hooks.afterEach(() => {
unpatch(ListRenderer.prototype, "web_enterprise.ListRendererDesktop");
unpatch(ListRenderer.prototype, "web_studio.ListRenderer");
});
QUnit.module("ListView");
QUnit.test("add custom field button with other optional columns", async function (assert) {
serverData.views["partner,false,list"] = `
<tree>
<field name="foo"/>
<field name="bar" optional="hide"/>
</tree>`;
const webClient = await createEnterpriseWebClient({ serverData });
await doAction(webClient, 3);
assert.containsOnce(target, ".o_list_view");
assert.containsOnce(target, ".o_list_view .o_optional_columns_dropdown_toggle");
await click(target.querySelector(".o_optional_columns_dropdown_toggle"));
assert.containsN(target, ".o_optional_columns_dropdown .dropdown-item", 2);
assert.containsOnce(target, ".o_optional_columns_dropdown .dropdown-item-studio");
await click(target.querySelector(".o_optional_columns_dropdown .dropdown-item-studio"));
assert.containsNone(target, ".modal-studio");
assert.containsOnce(
target,
".o_studio .o_web_studio_editor .o_web_studio_list_view_editor"
);
});
QUnit.test("add custom field button without other optional columns", async function (assert) {
// by default, the list in serverData doesn't contain optional fields
const webClient = await createEnterpriseWebClient({ serverData });
await doAction(webClient, 3);
assert.containsOnce(target, ".o_list_view");
assert.containsOnce(target, ".o_list_view .o_optional_columns_dropdown_toggle");
await click(target.querySelector(".o_optional_columns_dropdown_toggle"));
assert.containsOnce(target, ".o_optional_columns_dropdown .dropdown-item");
assert.containsOnce(target, ".o_optional_columns_dropdown .dropdown-item-studio");
await click(target.querySelector(".o_optional_columns_dropdown .dropdown-item-studio"));
assert.containsNone(target, ".modal-studio");
assert.containsOnce(
target,
".o_studio .o_web_studio_editor .o_web_studio_list_view_editor"
);
});
});

View File

@@ -0,0 +1,312 @@
/** @odoo-module */
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import {
getFixture,
patchWithCleanup,
click,
nextTick,
makeDeferred,
} from "@web/../tests/helpers/utils";
import { session } from "@web/session";
import { registry } from "@web/core/registry";
const fakeStudioService = {
start() {
return {
mode: null,
};
},
};
QUnit.module("Studio Approval", (hooks) => {
let target;
let serverData;
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
partner: {
fields: {
display_name: { string: "Displayed name", type: "char" },
int_field: { string: "int_field", type: "integer", sortable: true },
bar: { string: "Bar", type: "boolean" },
},
records: [
{
id: 1,
display_name: "first record",
int_field: 42,
bar: true,
},
{
id: 2,
display_name: "second record",
int_field: 27,
bar: true,
},
],
},
},
};
setupViewRegistries();
registry.category("services").add("studio", fakeStudioService);
});
QUnit.test("approval components are synchronous", async (assert) => {
const prom = makeDeferred();
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: `<form><button studio_approval="True" type="object" name="myMethod"/></form>`,
async mockRPC(route, args) {
if (args.method === "get_approval_spec") {
assert.step(args.method);
await prom;
return {
rules: [
{
id: 1,
group_id: [1, "Internal User"],
domain: false,
can_validate: true,
message: false,
exclusive_user: false,
},
],
entries: [],
groups: [[1, "Internal User"]],
};
}
},
});
assert.verifySteps(["get_approval_spec"]);
assert.containsOnce(target, "button .o_web_studio_approval .fa-circle-o-notch.fa-spin");
prom.resolve();
await nextTick();
assert.containsNone(target, "button .o_web_studio_approval .fa-circle-o-notch.fa-spin");
assert.containsOnce(target, "button .o_web_studio_approval .o_web_studio_approval_avatar");
});
QUnit.test("approval widget basic rendering", async function (assert) {
assert.expect(14);
patchWithCleanup(session, {
uid: 42,
});
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: `<form string="Partners">
<sheet>
<header>
<button type="object" name="someMethod" string="Apply Method" studio_approval="True"/>
</header>
<div name="button_box">
<button class="oe_stat_button" studio_approval="True" id="visibleStat">
<field name="int_field"/>
</button>
<button class="oe_stat_button" studio_approval="True"
attrs='{"invisible": [["bar", "=", true]]}' id="invisibleStat">
<field name="bar"/>
</button>
</div>
<group>
<group style="background-color: red">
<field name="display_name" studio_approval="True"/>
<field name="bar"/>
<field name="int_field"/>
</group>
</group>
<button type="object" name="anotherMethod"
string="Apply Second Method" studio_approval="True"/>
</sheet>
</form>`,
resId: 2,
mockRPC: function (route, args) {
if (args.method === "get_approval_spec") {
assert.step("fetch_approval_spec");
return Promise.resolve({
rules: [
{
id: 1,
group_id: [1, "Internal User"],
domain: false,
can_validate: true,
message: false,
exclusive_user: false,
},
],
entries: [],
groups: [[1, "Internal User"]],
});
}
},
});
// check that the widget was inserted on visible buttons only
assert.containsOnce(target, 'button[name="someMethod"] .o_web_studio_approval');
assert.containsOnce(target, "#visibleStat .o_web_studio_approval");
assert.containsNone(target, "#invisibleStat .o_web_studio_approval");
assert.containsOnce(target, 'button[name="anotherMethod"] .o_web_studio_approval');
assert.containsNone(target, ".o_group .o_web_studio_approval");
// should have fetched spec for exactly 3 buttons
assert.verifySteps(["fetch_approval_spec", "fetch_approval_spec", "fetch_approval_spec"]);
// display popover
await click(target, 'button[name="someMethod"] .o_web_studio_approval');
assert.containsOnce(target, ".o-approval-popover");
const popover = target.querySelector(".o-approval-popover");
assert.containsOnce(popover, ".o_web_studio_approval_no_entry");
assert.containsOnce(popover, ".o_web_approval_approve");
assert.containsOnce(popover, ".o_web_approval_reject");
assert.containsNone(popover, ".o_web_approval_cancel");
});
QUnit.test("approval check", async function (assert) {
assert.expect(4);
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: `<form string="Partners">
<sheet>
<header>
<button type="object" id="mainButton" name="someMethod"
string="Apply Method" studio_approval="True"/>
</header>
<group>
<group style="background-color: red">
<field name="display_name"/>
<field name="bar"/>
<field name="int_field"/>
</group>
</group>
</sheet>
</form>`,
resId: 2,
mockRPC: function (route, args) {
const rule = {
id: 1,
group_id: [1, "Internal User"],
domain: false,
can_validate: true,
message: false,
exclusive_user: false,
};
if (args.method === "get_approval_spec") {
assert.step("fetch_approval_spec");
return Promise.resolve({
rules: [rule],
entries: [],
groups: [[1, "Internal User"]],
});
} else if (args.method === "check_approval") {
assert.step("attempt_action");
return Promise.resolve({
approved: false,
rules: [rule],
entries: [],
});
} else if (args.method === "someMethod") {
/* the action of the button should not be
called, as the approval is refused! if this
code is traversed, the test *must* fail!
that's why it's not included in the expected count
or in the verifySteps call */
assert.step("should_not_happen!");
}
},
});
await click(target, "#mainButton");
// first render, handle click, rerender after click
assert.verifySteps(["fetch_approval_spec", "attempt_action", "fetch_approval_spec"]);
});
QUnit.test("approval widget basic flow", async function (assert) {
assert.expect(5);
patchWithCleanup(session, {
uid: 42,
});
let hasValidatedRule;
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: `<form string="Partners">
<sheet>
<header>
<button type="object=" name="someMethod" string="Apply Method" studio_approval="True"/>
</header>
<group>
<group style="background-color: red">
<field name="display_name"/>
<field name="bar"/>
<field name="int_field"/>
</group>
</group>
</sheet>
</form>`,
resId: 2,
mockRPC: function (route, args) {
if (args.method === "get_approval_spec") {
const spec = {
rules: [
{
id: 1,
group_id: [1, "Internal User"],
domain: false,
can_validate: true,
message: false,
exclusive_user: false,
},
],
entries: [],
groups: [[1, "Internal User"]],
};
if (hasValidatedRule !== undefined) {
spec.entries = [
{
id: 1,
approved: hasValidatedRule,
user_id: [42, "Some rando"],
write_date: "2020-04-07 12:43:48",
rule_id: [1, "someMethod/partner (Internal User)"],
model: "partner",
res_id: 2,
},
];
}
return Promise.resolve(spec);
} else if (args.method === "set_approval") {
hasValidatedRule = args.kwargs.approved;
assert.step(hasValidatedRule ? "approve_rule" : "reject_rule");
return Promise.resolve(true);
} else if (args.method === "delete_approval") {
hasValidatedRule = undefined;
assert.step("delete_approval");
return Promise.resolve(true);
}
},
});
// display popover and validate a rule, then cancel, then reject
await click(target, 'button[name="someMethod"] .o_web_studio_approval');
assert.containsOnce(target, ".o_popover");
await click(target, ".o_popover button.o_web_approval_approve");
await nextTick();
await click(target, ".o_popover button.o_web_approval_cancel");
await click(target, ".o_popover button.o_web_approval_reject");
assert.verifySteps(["approve_rule", "delete_approval", "reject_rule"]);
});
});