Odoo18-Base/enterprise-17.0/knowledge/static/tests/knowledge_form_view_tests.js
2025-01-06 10:57:38 +07:00

1014 lines
40 KiB
JavaScript

/** @odoo-module */
import { onMounted, onWillStart, status } from "@odoo/owl";
import { FormController } from "@web/views/form/form_controller";
import { registry } from "@web/core/registry";
import { click, getFixture, makeDeferred, mockSendBeacon, nextTick, patchWithCleanup } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import { renderToElement } from "@web/core/utils/render";
import { patch } from "@web/core/utils/patch";
import { HtmlField } from "@web_editor/js/backend/html_field";
import { parseHTML } from "@web_editor/js/editor/odoo-editor/src/utils/utils";
import { ArticlesStructureBehavior } from "@knowledge/components/behaviors/articles_structure_behavior/articles_structure_behavior";
import { TableOfContentBehavior } from "@knowledge/components/behaviors/table_of_content_behavior/table_of_content_behavior";
import { TemplateBehavior } from "@knowledge/components/behaviors/template_behavior/template_behavior";
import { KnowledgeArticleFormController } from "@knowledge/js/knowledge_controller";
import { knowledgeCommandsService } from "@knowledge/services/knowledge_commands_service";
const serviceRegistry = registry.category("services");
const articlesStructureSearch = {
records: [
{ id: 1, display_name: 'My Article', parent_id: false },
{ id: 2, display_name: 'Child 1', parent_id: 1 },
{ id: 3, display_name: 'Child 2', parent_id: 1 },
]
};
const articlesIndexSearch = {
records: articlesStructureSearch.records.concat([
{ id: 4, display_name: 'Grand-child 1', parent_id: 2 },
{ id: 5, display_name: 'Grand-child 2', parent_id: 2 },
{ id: 6, display_name: 'Grand-child 3', parent_id: 3 },
])
};
/**
* Insert an article structure (index or outline) in the target node. This will
* guarantee that the structure behavior is fully mounted before continuing.
* @param {HTMLElement} editable
* @param {HTMLElement} target
* @param {boolean} childrenOnly
*/
const insertArticlesStructure = async (editable, target, childrenOnly) => {
const articleStructureMounted = makeDeferred();
const wysiwyg = $(editable).data('wysiwyg');
const unpatch = patch(ArticlesStructureBehavior.prototype, {
setup() {
super.setup(...arguments);
onMounted(() => {
articleStructureMounted.resolve();
unpatch();
});
}
});
const selection = document.getSelection();
selection.removeAllRanges();
const range = new Range();
range.setStart(target, 0);
range.setEnd(target, 0);
selection.addRange(range);
await nextTick();
wysiwyg._insertArticlesStructure(childrenOnly);
await articleStructureMounted;
await nextTick();
};
let arch;
let fixture;
let formController;
let htmlField;
let htmlFieldPromise;
let record;
let resModel;
let serverData;
let type;
QUnit.module("Knowledge - Articles Structure Command", (hooks) => {
hooks.beforeEach(() => {
fixture = getFixture();
type = "form";
resModel = "knowledge.article";
serverData = {
models: {
"knowledge.article": {
fields: {
display_name: {string: "Displayed name", type: "char"},
body: {string: "Body", type: 'html'},
},
records: [{
id: 1,
display_name: "My Article",
body: '<p class="test_target"><br/></p>',
}],
methods: {
get_sidebar_articles() {
return {articles: [], favorite_ids: []};
}
}
}
}
};
arch = '<form js_class="knowledge_article_view_form">' +
'<sheet>' +
'<div class="o_knowledge_editor">' +
'<field name="body" widget="html"/>' +
'</div>' +
'</sheet>' +
'</form>';
setupViewRegistries();
});
QUnit.test('Check Articles Structure is correctly built', async function (assert) {
assert.expect(3);
await makeView({
type,
resModel,
serverData,
arch,
resId: 1,
mockRPC(route, args) {
if (args.method === 'web_search_read' && args.model === 'knowledge.article') {
return Promise.resolve(articlesStructureSearch);
}
}
});
const editable = fixture.querySelector('.odoo-editor-editable');
const target = editable.querySelector('p.test_target');
await insertArticlesStructure(editable, target, true);
// /articles_structure only considers the direct children - "Child 1" and "Child 2"
assert.containsN(editable, '.o_knowledge_articles_structure_content ol a', 2);
assert.containsOnce(editable, '.o_knowledge_articles_structure_content ol a:contains("Child 1")');
assert.containsOnce(editable, '.o_knowledge_articles_structure_content ol a:contains("Child 2")');
});
QUnit.test('Check Articles Index is correctly built - and updated', async function (assert) {
assert.expect(8);
let searchReadCallCount = 0;
await makeView({
type,
resModel,
serverData,
arch,
resId: 1,
mockRPC(route, args) {
if (args.method === 'web_search_read' && args.model === 'knowledge.article') {
if (searchReadCallCount === 0) {
searchReadCallCount++;
return Promise.resolve(articlesIndexSearch);
} else {
// return updated result (called when clicking on the refresh button)
return Promise.resolve({
records: articlesIndexSearch.records.concat([
{ id: 7, display_name: 'Grand-child 4', parent_id: 3 },
])
});
}
}
}
});
const editable = fixture.querySelector('.odoo-editor-editable');
const target = editable.querySelector('p.test_target');
await insertArticlesStructure(editable, target, false);
// /articles_index considers whole children - "Child 1" and "Child 2" and then their respective children
assert.containsN(editable, '.o_knowledge_articles_structure_content ol a', 5);
assert.containsOnce(editable, '.o_knowledge_articles_structure_content ol a:contains("Child 1")');
assert.containsOnce(editable, '.o_knowledge_articles_structure_content ol a:contains("Child 2")');
assert.containsOnce(editable,
'.o_knowledge_articles_structure_content ol:contains("Child 1") ol a:contains("Grand-child 1")');
assert.containsOnce(editable,
'.o_knowledge_articles_structure_content ol:contains("Child 1") ol a:contains("Grand-child 2")');
assert.containsOnce(editable,
'.o_knowledge_articles_structure_content ol:contains("Child 2") ol a:contains("Grand-child 3")');
// clicking on update yields an additional Grand-child (see 'mockRPC' here above)
// make sure our structure is correctly updated
await click(editable, '.o_knowledge_behavior_type_articles_structure button[title="Update"]');
await nextTick();
assert.containsN(editable, '.o_knowledge_articles_structure_content ol a', 6);
assert.containsOnce(editable,
'.o_knowledge_articles_structure_content ol:contains("Child 2") ol a:contains("Grand-child 4")');
});
});
//==============================================================================
// External Views
//==============================================================================
/**
* Insert an "External" view inside knowledge article.
* @param {HTMLElement} editable
*/
const testAppendBehavior = async (editable) => {
const wysiwyg = $(editable).data('wysiwyg');
const insertedDiv = renderToElement('knowledge.AbstractBehaviorBlueprint', {
behaviorType: "o_knowledge_behavior_type_template",
});
wysiwyg.appendBehaviorBlueprint(insertedDiv);
await nextTick();
};
QUnit.module("Knowledge - External View Insertion", (hooks) => {
hooks.beforeEach(() => {
fixture = getFixture();
type = "form";
resModel = "knowledge_article";
serverData = {
models: {
knowledge_article: {
fields: {
display_name: {string: "Displayed name", type: "char"},
body: {string: "Body", type: 'html'},
},
records: [{
id: 1,
display_name: "Insertion Article",
body: '\n<p>\n<br/>\n</p>\n',
}],
methods: {
get_sidebar_articles() {
return {articles: [], favorite_ids: []};
}
}
}
}
};
arch = '<form js_class="knowledge_article_view_form">' +
'<sheet>' +
'<div class="o_knowledge_editor">' +
'<field name="body" widget="html"/>' +
'</div>' +
'</sheet>' +
'</form>';
setupViewRegistries();
});
QUnit.test('Check that the insertion of views goes as expected', async function (assert) {
await makeView({
type,
resModel,
serverData,
arch,
resId: 1
});
const editable = fixture.querySelector('.odoo-editor-editable');
await testAppendBehavior(editable);
// We are checking if the anchor has been correctly inserted inside
// the article.
assert.containsOnce(editable, '.o_knowledge_behavior_anchor');
const anchor = editable.querySelector('.o_knowledge_behavior_anchor');
assert.notOk(anchor.nextSiblingElement, 'The inserted view should be the last element in the article');
});
});
//==============================================================================
// Macros
//==============================================================================
QUnit.module("Knowledge - Enable conditions for Macros", (hooks) => {
let formController;
hooks.beforeEach(() => {
patchWithCleanup(FormController.prototype, {
setup() {
super.setup(...arguments);
formController = this;
}
});
fixture = getFixture();
serverData = {
models: {
'knowledge.article': {
fields: {
name: {string: "Name", type: "char"},
body: {string: "Body", type: "html"},
},
records: [{
id: 1,
name: "Article",
body: "<p><br></p>",
}],
},
'product.product': {
fields: {
note: {string: "Note", type: "html", readonly: true},
memo: {string: "Memo", type: "html"},
description: {string: "Description", type: "html"},
comment: {string: "Comment", type: "html"},
narration: {string: "Narration", type: "html"},
delivery_instructions: {string: "Delivery instructions", type: "html"},
product_details: {string: "Product details", type: "html"},
user_feedback: {string: "User feedback", type: "html"},
},
records: [{
id: 1,
note: "<p>note</p>",
memo: "<p>memo</p>",
description: "<p>description</p>",
comment: "<p>comment</p>",
narration: "<p>narration</p>",
delivery_instructions: "<p>delivery instructions</p>",
product_details: "<p>product details</p>",
user_feedback: "<p>user feedback</p>",
}],
}
},
};
setupViewRegistries();
// Remove the mock_service (which is a dummy) and replace it with
// the real KnowledgeCommandsService.
serviceRegistry.remove("knowledgeCommandsService");
serviceRegistry.add("knowledgeCommandsService", knowledgeCommandsService);
});
QUnit.test("Don't validate a html field candidate from a forbidden model", async function (assert) {
assert.expect(1);
arch = `
<form>
<sheet>
<group>
<field name="name"/>
</group>
<notebook>
<page string='Test page' name='test_page'>
<field name='body'/>
</page>
</notebook>
</sheet>
</form>
`;
await makeView({
type: "form",
resModel: "knowledge.article",
serverData,
arch,
resId: 1,
});
formController._evaluateRecordCandidate();
// Forbidden models are defined in KNOWLEDGE_EXCLUDED_MODELS in the
// Knowledge form_controller_patch. They typically are models which
// have a heavily customized form view so a generic macro won't be able
// to navigate them. `knowledge.article` is one of them.
assert.equal(
formController.knowledgeCommandsService.getCommandsRecordInfo(),
null
);
});
QUnit.test("Validate a visible editable html field with priority", async function (assert) {
assert.expect(1);
arch = `
<form>
<sheet>
<group>
<field name="note"/>
<field name="memo" readonly="True"/>
<div invisible="True">
<field name="description"/>
</div>
<field name="comment" invisible="True"/>
<field name="product_details"/>
<field name="narration"/>
</group>
</sheet>
</form>
`;
await makeView({
type: "form",
resModel: "product.product",
serverData,
arch,
resId: 1,
});
formController._evaluateRecordCandidate();
// Here the selected html field should be `narration`, because
// every other field declared in the xml view before it is either
// readonly (on the model or specifically in the view),
// invisible (the field itself or one of its parent nodes),
// not in the priority list defined in the Knowledge
// form_controller_patch (KNOWLEDGE_RECORDED_FIELD_NAMES).
assert.equal(
formController.knowledgeCommandsService.getCommandsRecordInfo().fieldInfo.name,
"narration",
);
});
QUnit.test("Select a candidate in a named page, in order of declaration", async function (assert) {
assert.expect(1);
arch = `
<form>
<sheet>
<notebook>
<page string='Unnamed'>
<field name='product_details'/>
</page>
<page string='Named' name='named'>
<field name='user_feedback'/>
<field name='delivery_instructions'/>
</page>
</notebook>
</sheet>
</form>
`;
await makeView({
type: "form",
resModel: "product.product",
serverData,
arch,
resId: 1,
});
formController._evaluateRecordCandidate();
// Here the selected html field should be `user_feedback`, because
// it is the first field declared in the first named page of the
// xml view. This test also demonstrates that the alphabetical order
// is not considered, since `delivery_instructions` is not chosen.
assert.equal(
formController.knowledgeCommandsService.getCommandsRecordInfo().fieldInfo.name,
"user_feedback",
);
});
});
//==============================================================================
// Save Scenarios
//==============================================================================
QUnit.module("Knowledge - Ensure body save scenarios", (hooks) => {
hooks.beforeEach(() => {
patchWithCleanup(KnowledgeArticleFormController.prototype, {
setup() {
super.setup(...arguments);
formController = this;
}
});
htmlFieldPromise = makeDeferred();
patchWithCleanup(HtmlField.prototype, {
async startWysiwyg() {
await super.startWysiwyg(...arguments);
await nextTick();
htmlFieldPromise.resolve(this);
}
});
record = {
id: 1,
display_name: "Article",
body: "<p class='test_target'><br></p>",
};
serverData = {
models: {
knowledge_article: {
fields: {
display_name: {string: "Displayed name", type: "char"},
body: {string: "Body", type: "html"},
},
records: [record],
methods: {
get_sidebar_articles() {
return {articles: [], favorite_ids: []};
}
}
}
},
};
arch = `
<form js_class="knowledge_article_view_form">
<sheet>
<div class="o_knowledge_editor">
<field name="body" widget="html"/>
</div>
</sheet>
</form>
`;
setupViewRegistries();
});
//--------------------------------------------------------------------------
// TESTS
//--------------------------------------------------------------------------
QUnit.test("Ensure save on beforeLeave when Behaviors mutex is not idle and when it is", async function (assert) {
/**
* This test forces a call to the beforeLeave function of the KnowledgeFormController. It
* simulates that we leave the form view.
*
* The function will be called 2 times successively:
* 1- at a controlled time when a Behavior is in the process of being
* mounted, but not finished, to ensure that the saved article value is
* not corrupted (no missing html node).
* 2- at a controlled time when every Behavior was successfully mounted and
* no other Behavior is being mounted, to ensure that the saved article
* value contains information updated from the Behavior nodes.
*/
assert.expect(4);
let writeCount = 0;
await makeView({
type: "form",
resModel: "knowledge_article",
serverData,
arch,
resId: 1,
mockRPC(route, args) {
if (
route === '/web/dataset/call_kw/knowledge_article/web_save' &&
args.model === 'knowledge_article'
) {
if (writeCount === 0) {
// The first expected `write` value should be the
// unmodified blueprint, since OWL has not finished
// mounting the Behavior nodes.
assert.notOk(editor.editable.querySelector('[data-prop-name="content"]'));
assert.equal(editor.editable.querySelector('.witness').textContent, "WITNESS_ME!");
} else if (writeCount === 1) {
// Change the expected `write` value, the "witness node"
// should have been cleaned since it serves no purpose
// for this Behavior in the OWL template.
assert.notOk(editor.editable.querySelector('.witness'));
assert.equal(editor.editable.querySelector('[data-prop-name="content"]').innerHTML, "<p><br></p>");
} else {
// This should never be called and will fail if it is.
assert.ok(writeCount === 1, "Write should only be called 2 times during this test");
}
writeCount += 1;
}
}
});
// Let the htmlField be mounted and recover the Component instance.
htmlField = await htmlFieldPromise;
const editor = htmlField.wysiwyg.odooEditor;
// Patch to control when the next mounting is done.
const isAtWillStart = makeDeferred();
const pauseWillStart = makeDeferred();
const unpatch = patch(TemplateBehavior.prototype, {
setup() {
super.setup(...arguments);
onWillStart(async () => {
isAtWillStart.resolve();
await pauseWillStart;
unpatch();
});
}
});
// Introduce a Behavior blueprint with an "witness node" that does not
// serve any purpose except for the fact that it should be left
// untouched until OWL completely finishes its mounting process
// and at that point it will be replaced by the rendered OWL template.
const behaviorHTML = `
<div class="o_knowledge_behavior_anchor o_knowledge_behavior_type_template">
<div class="witness">WITNESS_ME!</div>
</div>
`;
const anchor = parseHTML(editor.document, behaviorHTML).firstChild;
const target = editor.editable.querySelector(".test_target");
// The BehaviorState MutationObserver will try to start the mounting
// process for the Behavior with the anchor node as soon as it is in
// the DOM.
editor.editable.replaceChild(anchor, target);
// Validate the mutation as a normal user history step.
editor.historyStep();
// Wait for the Template Behavior onWillStart lifecycle step.
await isAtWillStart;
// Attempt a save when the mutex is not idle. It should save the
// unchanged blueprint of the Behavior.
await formController.beforeLeave();
// Allow the Template Behavior to go past the `onWillStart` lifecycle
// step.
pauseWillStart.resolve();
// Wait for the mount mutex to be idle. The Template Behavior should
// be fully mounted after this.
await htmlField.mountBehaviors();
// Attempt a save when the mutex is idle.
await formController.beforeLeave();
});
QUnit.test("Ensure save on beforeUnload when Behaviors mutex is not idle and when it is", async function (assert) {
/**
* This test forces a call to the beforeUnload function of the KnowledgeFormController. It
* simulates that the close the browser/tab when being on that form view.
*
* The function will be called 2 times successively:
* 1- at a controlled time when a Behavior is in the process of being
* mounted, but not finished, to ensure that the saved article value is
* not corrupted (no missing html node).
* 2- at a controlled time when every Behavior was successfully mounted and
* no other Behavior is being mounted, to ensure that the saved article
* value contains information updated from the Behavior nodes.
*/
mockSendBeacon((route) => {
if (route === '/web/dataset/call_kw/knowledge_article/web_save') {
if (writeCount === 0) {
// The first expected `write` value should be the
// unmodified blueprint, since OWL has not finished
// mounting the Behavior nodes.
assert.notOk(editor.editable.querySelector('[data-prop-name="content"]'));
assert.equal(editor.editable.querySelector('.witness').textContent, "WITNESS_ME!");
} else if (writeCount === 1) {
// Change the expected `write` value, the "witness node"
// should have been cleaned since it serves no purpose
// for this Behavior in the OWL template.
assert.notOk(editor.editable.querySelector('.witness'));
assert.equal(editor.editable.querySelector('[data-prop-name="content"]').innerHTML, "<p><br></p>");
} else {
// This should never be called and will fail if it is.
assert.ok(writeCount === 1, "Write should only be called 2 times during this test");
}
writeCount += 1;
}
});
assert.expect(4);
let writeCount = 0;
await makeView({
type: "form",
resModel: "knowledge_article",
serverData,
arch,
resId: 1,
});
// Let the htmlField be mounted and recover the Component instance.
htmlField = await htmlFieldPromise;
const editor = htmlField.wysiwyg.odooEditor;
// Patch to control when the next mounting is done.
const isAtWillStart = makeDeferred();
const pauseWillStart = makeDeferred();
const unpatch = patch(TemplateBehavior.prototype, {
setup() {
super.setup(...arguments);
onWillStart(async () => {
isAtWillStart.resolve();
await pauseWillStart;
unpatch();
});
}
});
// Introduce a Behavior blueprint with an "witness node" that does not
// serve any purpose except for the fact that it should be left
// untouched until OWL completely finishes its mounting process
// and at that point it will be replaced by the rendered OWL template.
const behaviorHTML = `
<div class="o_knowledge_behavior_anchor o_knowledge_behavior_type_template">
<div class="witness">WITNESS_ME!</div>
</div>
`;
const anchor = parseHTML(editor.document, behaviorHTML).firstChild;
const target = editor.editable.querySelector(".test_target");
// The BehaviorState MutationObserver will try to start the mounting
// process for the Behavior with the anchor node as soon as it is in
// the DOM.
editor.editable.replaceChild(anchor, target);
// Validate the mutation as a normal user history step.
editor.historyStep();
// Wait for the Template Behavior onWillStart lifecycle step.
await isAtWillStart;
// Attempt a save when the mutex is not idle. It should save the
// unchanged blueprint of the Behavior.
await formController.beforeUnload();
// Allow the Template Behavior to go past the `onWillStart` lifecycle
// step.
pauseWillStart.resolve();
// Wait for the mount mutex to be idle. The Template Behavior should
// be fully mounted after this.
await htmlField.mountBehaviors();
// Attempt a save when the mutex is idle.
await formController.beforeUnload();
});
});
//==============================================================================
// Table of Contents
//==============================================================================
/**
* Insert a Table Of Content (TOC) in the target node. This will guarantee that
* the TOC behavior is fully mounted before continuing.
* @param {HTMLElement} editable - Root HTMLElement of the editor
* @param {HTMLElement} target - Target node
*/
const insertTableOfContent = async (editable, target) => {
const tocMounted = makeDeferred();
const wysiwyg = $(editable).data('wysiwyg');
const unpatch = patch(TableOfContentBehavior.prototype, {
setup() {
super.setup(...arguments);
onMounted(() => {
tocMounted.resolve();
unpatch();
});
}
});
const selection = document.getSelection();
selection.removeAllRanges();
const range = new Range();
range.setStart(target, 0);
range.setEnd(target, 0);
selection.addRange(range);
await nextTick();
wysiwyg._insertTableOfContent();
await tocMounted;
await nextTick();
};
/**
* @param {Object} assert - QUnit assert object used to trigger asserts and exceptions
* @param {HTMLElement} editable - Root HTMLElement of the editor
* @param {Array[Object]} expectedHeadings - List of headings that should appear in the toc of the editable
*/
const assertHeadings = (assert, editable, expectedHeadings) => {
const allHeadings = Array.from(editable.querySelectorAll('a.o_knowledge_toc_link'));
for (let index = 0; index < expectedHeadings.length; index++) {
const { title, depth } = expectedHeadings[index];
const headingSelector = `a:contains("${title}").o_knowledge_toc_link_depth_${depth}`;
// we have the heading in the DOM
assert.containsOnce(editable, headingSelector);
const $headingEl = $(editable).find(headingSelector);
// it has the correct index (as item order is important)
assert.equal(index, allHeadings.indexOf($headingEl[0]));
}
};
QUnit.module("Knowledge Table of Content", (hooks) => {
hooks.beforeEach(() => {
fixture = getFixture();
type = "form";
resModel = "knowledge_article";
serverData = {
models: {
knowledge_article: {
fields: {
display_name: {string: "Displayed name", type: "char"},
body: {string: "Body", type: 'html'},
},
records: [{
id: 1,
display_name: "My Article",
body: '<p class="test_target"><br/></p>' +
'<h1>Main 1</h1>' +
'<h2>Sub 1-1</h2>' +
'<h3>Sub 1-1-1</h3>' +
'<h3>Sub 1-1-2</h3>' +
'<h2>Sub 1-2</h2>' +
'<h3>Sub 1-2-1</h3>' +
'<h1>Main 2</h1>' +
'<h3>Sub 2-1</h3>' +
'<h3>Sub 2-2</h3>' +
'<h4>Sub 2-2-1</h4>' +
'<h5>Sub 2-2-1-1</h5>' +
'<h3>Sub 2-3</h3>',
}, {
id: 2,
display_name: "My Article",
body: '<p class="test_target"><br/></p>' +
'<h2>Main 1</h2>' +
'<h3>Sub 1-1</h3>' +
'<h4>Sub 1-1-1</h4>' +
'<h4>Sub 1-1-2</h4>' +
'<h1>Main 2</h1>' +
'<h2>Sub 2-1</h2>',
}, {
id: 3,
display_name: "My Article",
body: `<p class="test_target"><br/></p>
<h3>Main 1</h3>
<h2>Main 2</h2>`,
}],
methods: {
get_sidebar_articles() {
return {articles: [], favorite_ids: []};
}
},
},
}
};
arch = '<form js_class="knowledge_article_view_form">' +
'<sheet>' +
'<div class="o_knowledge_editor d-flex flex-grow-1">' +
'<field name="body" widget="html"/>' +
'</div>' +
'</sheet>' +
'</form>';
setupViewRegistries();
});
QUnit.test("Check Table of Content is correctly built", async function (assert) {
assert.expect(24);
await makeView({
type,
resModel,
serverData,
arch,
resId: 1,
});
const editable = fixture.querySelector('.odoo-editor-editable');
const target = editable.querySelector('p.test_target');
await insertTableOfContent(editable, target);
const expectedHeadings = [
{title: 'Main 1', depth: 0},
{title: 'Sub 1-1', depth: 1},
{title: 'Sub 1-1-1', depth: 2},
{title: 'Sub 1-1-2', depth: 2},
{title: 'Sub 1-2', depth: 1},
{title: 'Sub 1-2-1', depth: 2},
{title: 'Main 2', depth: 0},
// the next <h3>'s should be at depth 1, because we don't have any <h2> in this subtree
{title: 'Sub 2-1', depth: 1},
{title: 'Sub 2-2', depth: 1},
{title: 'Sub 2-2-1', depth: 2},
{title: 'Sub 2-2-1-1', depth: 3},
// the next <h3> should be at depth 1, because we don't have any <h2> in this subtree
{title: 'Sub 2-3', depth: 1},
];
assertHeadings(assert, editable, expectedHeadings);
});
QUnit.test('Check Table of Content is correctly built - starting with H2', async function (assert) {
assert.expect(12);
await makeView({
type,
resModel,
serverData,
arch,
resId: 2,
});
const editable = fixture.querySelector('.odoo-editor-editable');
const target = editable.querySelector('p.test_target');
await insertTableOfContent(editable, target);
const expectedHeadings = [
// The "Main 1" section is a <h2>, but it should still be at depth 0
// as there is no <h1> above it
{title: 'Main 1', depth: 0},
{title: 'Sub 1-1', depth: 1},
{title: 'Sub 1-1-1', depth: 2},
{title: 'Sub 1-1-2', depth: 2},
{title: 'Main 2', depth: 0},
{title: 'Sub 2-1', depth: 1},
];
assertHeadings(assert, editable, expectedHeadings);
});
QUnit.test('Check Table of Content is correctly built - starting with H3 followed by H2', async function (assert) {
assert.expect(4);
await makeView({
type,
resModel,
serverData,
arch,
resId: 3,
});
const editable = fixture.querySelector('.odoo-editor-editable');
const target = editable.querySelector('p.test_target');
await insertTableOfContent(editable, target);
const expectedHeadings = [
// The "Main 1" section is a <h3> at depth 0, and the next "Main 2" section
// is <h2>, which should still be at the 0 depth instead of 1
{title: 'Main 1', depth: 0},
{title: 'Main 2', depth: 0},
];
assertHeadings(assert, editable, expectedHeadings);
});
});
QUnit.module("Knowledge - Silenced Failure Cases (Recoverable)", (hooks) => {
hooks.beforeEach(() => {
htmlFieldPromise = makeDeferred();
patchWithCleanup(HtmlField.prototype, {
async startWysiwyg() {
await super.startWysiwyg(...arguments);
await nextTick();
htmlFieldPromise.resolve(this);
}
});
fixture = getFixture();
type = "form";
resModel = "knowledge_article";
record = {
id: 1,
display_name: "Article",
body: "<p class='test_target'><br></p>",
};
serverData = {
models: {
knowledge_article: {
fields: {
display_name: {string: "Displayed name", type: "char"},
body: {string: "Body", type: "html"},
},
records: [record],
methods: {
get_sidebar_articles() {
return {articles: [], favorite_ids: []};
}
}
}
},
};
arch = `
<form js_class="knowledge_article_view_form">
<sheet>
<div class="o_knowledge_editor">
<field name="body" widget="html"/>
</div>
</sheet>
</form>
`;
setupViewRegistries();
});
QUnit.test("Insertion target node disappeared before mounting and recovery (mount another Behavior afterwards)", async function (assert) {
await makeView({
type,
resModel,
serverData,
arch,
resId: 1,
});
htmlField = await htmlFieldPromise;
const editor = htmlField.wysiwyg.odooEditor;
// Patch to control when the mounting is done
const isAtWillStart = makeDeferred();
const pauseWillStart = makeDeferred();
const unpatch = patch(TemplateBehavior.prototype, {
setup() {
super.setup(...arguments);
onWillStart(async () => {
isAtWillStart.resolve();
await pauseWillStart;
unpatch();
});
}
});
// Insert a Behavior to mount in the editable
const behaviorHTML = `
<div class="o_knowledge_behavior_anchor o_knowledge_behavior_type_template">
<div data-prop-name="content">
<p><br></p>
</div>
</div>
<p><br></p>
`;
const anchor = parseHTML(editor.document, behaviorHTML).firstChild;
const target = editor.editable.querySelector(".test_target");
editor.observerUnactive('test_insert_behavior');
editor.editable.replaceChild(anchor, target);
editor.observerActive('test_insert_behavior');
// Wait for the Behavior mounting process to be almost finished
await isAtWillStart;
// Remove the target node from the editable
editor.observerUnactive('test_insert_behavior');
editor.editable.replaceChild(target, anchor);
editor.observerActive('test_insert_behavior');
// unlock onWillstart so that the mouting can continue
pauseWillStart.resolve();
// wait for mount mutex
await htmlField.mountBehaviors();
// Ensure that the Behavior is not in the editable but in the Handler
assert.notOk(editor.editable.querySelector('.o_knowledge_behavior_type_template'), "The Behavior cannot be mounted in the editable since its target anchor was removed.");
const behavior = htmlField.behaviorState.handlerRef.el.querySelector('.o_knowledge_behavior_type_template');
assert.equal(status(behavior.oKnowledgeBehavior.root.component), "mounted");
// Put the anchor blueprint in the editable again, this time we'll allow
// it to be mounted in the editable
editor.observerUnactive('test_insert_behavior');
editor.editable.replaceChild(anchor, target);
editor.observerActive('test_insert_behavior');
// wait for mount mutex
await htmlField.mountBehaviors();
// Ensure that the obsolete Behavior was destroyed and the new one is
// mounted in the editable
assert.notOk(htmlField.behaviorState.handlerRef.el.querySelector('.o_knowledge_behavior_type_template'), "The obsolete Behavior should have been destroyed.");
const newBehavior = editor.editable.querySelector('.o_knowledge_behavior_type_template');
assert.equal(status(newBehavior.oKnowledgeBehavior.root.component), "mounted");
assert.notEqual(behavior, newBehavior);
});
});