diff --git a/addons/knowledge/__manifest__.py b/addons/knowledge/__manifest__.py
index e7a8af562bc..1426b720998 100644
--- a/addons/knowledge/__manifest__.py
+++ b/addons/knowledge/__manifest__.py
@@ -51,6 +51,15 @@
'knowledge/static/src/js/widgets/knowledge_emoji_picker.js',
'knowledge/static/src/webclient/commands/*.js',
'knowledge/static/src/models/*/*.js',
+ 'knowledge/static/src/js/form_controller.js',
+ 'knowledge/static/src/js/form_renderer.js',
+ 'knowledge/static/src/js/knowledge_macros.js',
+ 'knowledge/static/src/js/knowledge_behaviors.js',
+ 'knowledge/static/src/js/knowledge_toolbars.js',
+ 'knowledge/static/src/js/knowledge_field_html_injector.js',
+ 'knowledge/static/src/js/knowledge_plugin.js',
+ 'knowledge/static/src/js/field_html.js',
+ 'knowledge/static/src/js/knowledge_service.js',
],
'web.assets_frontend': [
'knowledge/static/src/scss/knowledge_frontend.scss',
@@ -58,17 +67,27 @@
'knowledge/static/src/js/knowledge_frontend.js',
'knowledge/static/src/js/tools/tree_panel_mixin.js',
],
+ 'web_editor.assets_wysiwyg': [
+ 'knowledge/static/src/js/wysiwyg.js',
+ 'knowledge/static/src/js/knowledge_toolbars_edit.js',
+ 'knowledge/static/src/js/knowledge_clipboard_whitelist.js'
+ ],
'web.assets_qweb': [
'knowledge/static/src/components/*/*.xml',
+ 'knowledge/static/src/xml/knowledge_editor.xml',
'knowledge/static/src/xml/knowledge_templates.xml',
'knowledge/static/src/xml/chatter_topbar.xml',
'knowledge/static/src/xml/knowledge_command_palette.xml',
+ 'knowledge/static/src/xml/knowledge_toolbars.xml',
],
'web.assets_tests': [
'knowledge/static/tests/tours/*.js',
],
'web.qunit_suite_tests': [
- 'knowledge/static/src/tests/test_services.js',
+ 'knowledge/static/tests/test_services.js',
+ ],
+ 'web.qunit_mobile_suite_tests': [
+ 'knowledge/static/tests/test_services.js',
],
},
}
diff --git a/addons/knowledge/static/src/js/field_html.js b/addons/knowledge/static/src/js/field_html.js
new file mode 100644
index 00000000000..c91b3fe4531
--- /dev/null
+++ b/addons/knowledge/static/src/js/field_html.js
@@ -0,0 +1,85 @@
+/** @odoo-module */
+
+import FieldHtml from 'web_editor.field.html';
+import {FieldHtmlInjector} from './knowledge_field_html_injector';
+import {KnowledgePlugin} from './knowledge_plugin';
+
+FieldHtml.include({
+ /**
+ * @override
+ */
+ _renderReadonly: function () {
+ const prom = this._super.apply(this, arguments);
+ if (prom) {
+ return prom.then(function () {
+ return this._addFieldHtmlInjector();
+ }.bind(this));
+ } else {
+ return this._addFieldHtmlInjector();
+ }
+ },
+ /**
+ * Add a @see FieldHtmlInjector to this field_html, which will add temporary
+ * @see KnowledgeBehavior and/or @see KnowledgeToolbar to @see OdooEditor
+ * blocks. Delegate control of the editor to the injector, mainly to access
+ * history methods. i.e.: @see KnowledgeToolbar insertion in the dom should
+ * not be registered in the user history
+ *
+ * @returns {Promise}
+ */
+ _addFieldHtmlInjector: function () {
+ if (this.$content && this.$content.length) {
+ const editor = (this.mode === 'edit' && this.wysiwyg) ? this.wysiwyg.odooEditor : null;
+ const fieldHtmlInjector = new FieldHtmlInjector(this, this.mode, this.$content[0], editor);
+ return fieldHtmlInjector.appendTo(this.el);
+ }
+ },
+ /**
+ * Some events can cause the insertion of new elements which may need a
+ * @see KnowledgeBehavior and/or @see KnowledgeToolbar , so the
+ * @see FieldHtmlInjector needs to be notified.
+ *
+ * @override
+ */
+ _onLoadWysiwyg: function () {
+ this._super.apply(this, arguments);
+ this._addFieldHtmlInjector();
+ /**
+ * setTimeout is called so that the refresh_injector event is dispatched
+ * at the start of the next event cycle. This is to ensure that other
+ * handlers for those events have execution priority over the refresh.
+ * In particular, the content to drop in the editor would be inserted
+ * after the refreshInjector without setTimeout.
+ */
+ const refreshInjector = () => setTimeout(this.refreshInjector.bind(this));
+ this.wysiwyg.odooEditor.addEventListener('historyUndo', refreshInjector);
+ this.wysiwyg.odooEditor.addEventListener('historyRedo', refreshInjector);
+ this.$content[0].addEventListener('paste', refreshInjector);
+ this.$content[0].addEventListener('drop', refreshInjector);
+ },
+ /**
+ * Add a plugin (@see KnowledgePlugin ) for the @see OdooEditor which
+ * implements @see cleanForSave in order to remove @see KnowledgeToolbar
+ * before saving a record.
+ *
+ * @override
+ */
+ _getWysiwygOptions: function () {
+ const options = this._super.apply(this, arguments);
+ if (Array.isArray(options.editorPlugins)) {
+ options.editorPlugins.push(KnowledgePlugin);
+ } else {
+ options.editorPlugins = [KnowledgePlugin];
+ }
+ return options;
+ },
+ /**
+ * Notify the @see FieldHtmlInjector to render and apply KnowledgeToolbar
+ * and KnowledgeBehavior where it is needed.
+ */
+ refreshInjector: function () {
+ if (this.$content) {
+ this.$content.trigger('refresh_injector');
+ }
+ },
+});
diff --git a/addons/knowledge/static/src/js/form_controller.js b/addons/knowledge/static/src/js/form_controller.js
new file mode 100644
index 00000000000..bde245e2e8b
--- /dev/null
+++ b/addons/knowledge/static/src/js/form_controller.js
@@ -0,0 +1,192 @@
+/** @odoo-module **/
+
+import FormController from 'web.FormController';
+import { Domain } from '@web/core/domain';
+
+FormController.include({
+ /**
+ * Knowledge articles can interact with some records with the help of the
+ * @see KnowledgeService. Those records need to have a field whose name is
+ * in @see knowledgeTriggerFieldNames. This list is ordered and the first
+ * match found in a record will take precedence. Once a match is found, it
+ * is stored in the KnowledgeService to be accessed later by an article.
+ *
+ * @override
+ */
+ init: function (parent, model, renderer, params) {
+ this._super.apply(this, arguments);
+ this.knowledgeRecordFieldNames = [
+ 'note', 'memo', 'description', 'comment', 'narration', 'additional_note', 'internal_notes', 'notes'
+ ];
+ if (this._isPotentialKnowledgeRecord()) {
+ this._searchKnowledgeRecord();
+ }
+ if (this.ignoreKnowledgeRecordSearch) {
+ this._unregisterObsoleteKnowledgeRecords(this.renderer.breadcrumbs);
+ }
+ },
+ /**
+ * Since Knowledge toolbars are cleaned before saving the record
+ * @see KnowledgePlugin , the renderer needs to render them again once it is
+ * done.
+ *
+ * @override
+ * @param {string} recordID
+ */
+ saveRecord: async function (recordID) {
+ const changedFields = this._super(...arguments);
+ recordID = recordID || this.handle;
+ if (recordID) {
+ changedFields.then(() => {
+ this.renderer.refreshFieldHtmlInjectors(recordID);
+ });
+ }
+ return changedFields;
+ },
+ /**
+ * Notify the @see KnowledgeService that the record was updated
+ *
+ * @override
+ */
+ update: async function (params, options) {
+ await this._super(...arguments);
+ if (this._isPotentialKnowledgeRecord()) {
+ this._searchKnowledgeRecord();
+ }
+ if (this.ignoreKnowledgeRecordSearch) {
+ this._unregisterObsoleteKnowledgeRecords(this.renderer.breadcrumbs);
+ }
+ },
+ /**
+ * Evaluate the provided breadcrumbs sequence against currently recorded
+ * records in @see KnowledgeService to remove them if they are obsolete.
+ * Breadcrumb information does not contain the res_id of the record for a
+ * form view, and the actionService does not allow to access the breadcrumb
+ * controller externally. This is why the full breadcrumb string sequence is
+ * used here as an identifier.
+ *
+ * @param {Array} breadcrumbs
+ * @param {boolean} revoke whether the record is deemed obsolete if a match
+ * is found or not.
+ */
+ _unregisterObsoleteKnowledgeRecords: function (breadcrumbs, revoke = false) {
+ const records = this.call('knowledgeService', 'getRecords');
+ let isObsolete = revoke;
+ for (let record of records) {
+ if (record.breadcrumbs.length > breadcrumbs.length) {
+ isObsolete = !revoke;
+ } else {
+ const slicedBreadcrumbs = breadcrumbs.slice(0, record.breadcrumbs.length);
+ if (_.isEqual(slicedBreadcrumbs, record.breadcrumbs)) {
+ isObsolete = revoke;
+ } else {
+ isObsolete = !revoke;
+ }
+ }
+ if (isObsolete) {
+ this.call('knowledgeService', 'unregisterRecord', record);
+ }
+ }
+ },
+ /**
+ * Conditions for a record to be usable by a Knowledge article:
+ * - The current view is not a Knowledge form
+ * - has a ControlPanel which uses breadcrumbs
+ * - is the result of an action
+ * - displays an existing record (already stored in DB)
+ * (saved new records will be registered upon @see update )
+ */
+ _isPotentialKnowledgeRecord: function () {
+ return !this.ignoreKnowledgeRecordSearch && this.withControlPanel && this.controlPanelProps.withBreadcrumbs &&
+ this.controlPanelProps.action && this.controlPanelProps.action.controllers &&
+ this.handle && !this.model.localData[this.handle].isNew();
+ },
+ /**
+ * @param {Object} record raw record
+ * @param {string} modifier modifier as registered in the view (xml)
+ * @returns {boolean} whether the domain includes this record
+ */
+ _readModifier: function (record, modifier) {
+ if (!modifier) {
+ // falsy modifier
+ return false;
+ }
+ let value = false;
+ try {
+ // unaware of context
+ const preDomain = new Domain(modifier);
+ // aware of context
+ const domain = new Domain(preDomain.toList(record.context));
+ value = domain.contains(record.data);
+ } catch (_error) {
+ // truthy modifier
+ return true;
+ }
+ return value;
+ },
+ /**
+ * Evaluate the current record and notify @see KnowledgeService if it can or
+ * could be used in a Knowledge article.
+ *
+ * @private
+ */
+ _searchKnowledgeRecord: function () {
+ const record = this.model.get(this.handle, {raw: true});
+ const controller = this.controlPanelProps.action.controllers.form;
+ const breadcrumbs = this.controlPanelProps.breadcrumbs.slice();
+ // add the current breadcrumbs information
+ breadcrumbs.push({
+ title: record.data.display_name,
+ controllerID: controller.jsId,
+ });
+ /**
+ * If the current potential record has exactly the same breadcrumbs
+ * sequence as another record registered in the @see KnowledgeService,
+ * the previous record should be unregistered here because this problem
+ * will not be caught later, as the Knowledge form view only checks
+ * whether its breadcrumbs sequence contains a record's breadcrumbs
+ * sequence, regardless of the fact that the current potential record
+ * may not have been registered in the service.
+ *
+ * This call could be omitted if the breadcrumbs would also store the
+ * related res_id if any, but currently two records with the same
+ * display name and model will have exactly the same breadcrumbs
+ * information (controllerID and title).
+ */
+ this._unregisterObsoleteKnowledgeRecords(breadcrumbs, true);
+ const hasMessageIds = this.renderer.chatterFields && this.renderer.chatterFields.hasMessageIds;
+ const view = this.controlPanelProps.view;
+ const fields = view.viewFields;
+ const formFields = view.fieldsInfo.form;
+ const knowledgeRecord = {
+ res_id: record.res_id,
+ res_model: view.model,
+ breadcrumbs: breadcrumbs,
+ fieldNames: [],
+ };
+ if (hasMessageIds) {
+ knowledgeRecord.withChatter = true;
+ this.call('knowledgeService', 'registerRecord', knowledgeRecord);
+ }
+ for (let fieldName of this.knowledgeRecordFieldNames) {
+ if (fieldName in formFields && fields[fieldName].type === 'html' && !fields[fieldName].readonly) {
+ const readonlyModifier = formFields[fieldName].modifiers.readonly;
+ const invisibleModifier = formFields[fieldName].modifiers.invisible;
+ if (this._readModifier(record, readonlyModifier) || this._readModifier(record, invisibleModifier)) {
+ continue;
+ }
+ knowledgeRecord.fieldNames.push({
+ name: fieldName,
+ string: fields[fieldName].string,
+ });
+ }
+ }
+ /**
+ * While a field by itself can be visible and not readonly, its
+ * group / notebook pane could be invisible. Another check will be done
+ * after rendering to determine if any of the catched fields are in fact
+ * usable by the user. @see FormRenderer
+ */
+ this.call('knowledgeService', 'pushToValidateWithHtmlField', knowledgeRecord);
+ },
+});
diff --git a/addons/knowledge/static/src/js/form_renderer.js b/addons/knowledge/static/src/js/form_renderer.js
new file mode 100644
index 00000000000..2770b71ecdd
--- /dev/null
+++ b/addons/knowledge/static/src/js/form_renderer.js
@@ -0,0 +1,77 @@
+/** @odoo-module **/
+
+import FormRenderer from 'web.FormRenderer';
+import core from 'web.core';
+
+FormRenderer.include({
+ /**
+ * Refresh the injector of html fields (to update toolbars and behaviors)
+ * @see FieldHtmlInjector
+ *
+ * @param {string} recordID
+ */
+ refreshFieldHtmlInjectors: function (recordID) {
+ const recordWidgets = this.allFieldWidgets[recordID];
+ if (!recordWidgets) {
+ return;
+ }
+ recordWidgets.filter(widget => widget.field.type === 'html' && typeof widget.refreshInjector === 'function')
+ .forEach(widget => {
+ widget.refreshInjector();
+ });
+ },
+ /**
+ * Once the view is rendered, check whether a record's field registered in
+ * @see KnowledgeService is available to the user directly or through
+ * an inactive pane of the notebook.
+ *
+ * @override
+ */
+ _render: async function () {
+ await this._super(...arguments);
+ core.bus.once('DOM_updated', this, function () {
+ let record = this.call('knowledgeService', 'popToValidateWithHtmlField');
+ while (record) {
+ for (let fieldName = record.fieldNames.shift(); fieldName; fieldName = record.fieldNames.shift()) {
+ if (this._checkAvailability(fieldName.name)) {
+ record.fieldNames = [fieldName];
+ record.withHtmlField = true;
+ break;
+ }
+ }
+ if (record.withHtmlField) {
+ this.call('knowledgeService', 'registerRecord', record);
+ break;
+ }
+ record = this.call('knowledgeService', 'popToValidateWithHtmlField');
+ }
+ });
+ },
+ /**
+ * Inspect the view to check whether the field matching "name" is accessible
+ * in the view at least once, directly or through a notebook pane switch.
+ *
+ * @param {string} name of a field of the record
+ * @returns {boolean}
+ */
+ _checkAvailability: function (name) {
+ const selector = `.oe_form_field_html[name="${name}"]`;
+ const $sel = $(selector);
+ for (let i = 0; i < $sel.length; i++) {
+ if ($sel.eq(i).is(':visible:hasVisibility')) {
+ return true;
+ } else if ($sel[i].closest('.o_invisible_modifier')) {
+ continue;
+ } else {
+ let pane = $sel[i].closest('.tab-pane:not(.active)');
+ if (pane) {
+ let $paneSwitch = $(`[data-toggle="tab"][href*="${pane.id}"]`);
+ if ($paneSwitch.is(':visible:hasVisibility')) {
+ return true;
+ }
+ }
+ }
+ }
+ return false;
+ }
+});
diff --git a/addons/knowledge/static/src/js/knowledge_behaviors.js b/addons/knowledge/static/src/js/knowledge_behaviors.js
new file mode 100644
index 00000000000..28d08c09acc
--- /dev/null
+++ b/addons/knowledge/static/src/js/knowledge_behaviors.js
@@ -0,0 +1,88 @@
+/** @odoo-module */
+
+import Class from 'web.Class';
+
+/**
+ * Behavior to be injected through @see FieldHtmlInjector to @see OdooEditor
+ * blocks which have specific classes calling for such behaviors.
+ *
+ * A typical usage could be the following:
+ * - An @see OdooEditor block like /template has the generic class:
+ * @see o_knowledge_behavior_anchor to signify that it needs to have a
+ * behavior injected.
+ * - This block also has the specific class:
+ * @see o_knowledge_behavior_type_[behaviorType] which specifies the type of
+ * behavior that needs to be injected. @see FieldHtmlInjector has a dictionary
+ * mapping those classes to the correct behavior class.
+ *
+ * The @see KnowledgeBehavior is a basic behavior intended to be overriden for
+ * more complex implementations
+ */
+const KnowledgeBehavior = Class.extend({
+ /**
+ * @param {Widget} handler @see FieldHtmlInjector which has access to
+ * widget specific functions
+ * @param {Element} anchor dom node to apply the behavior to
+ * @param {string} mode edit/readonly
+ */
+ init: function (handler, anchor, mode) {
+ this.handler = handler;
+ this.anchor = anchor;
+ this.mode = mode;
+ if (this.handler.editor) {
+ this.handler.editor.observerUnactive('knowledge_attributes');
+ }
+ this.applyAttributes();
+ if (this.handler.editor) {
+ this.handler.editor.observerActive('knowledge_attributes');
+ }
+ this.applyListeners();
+ },
+ /**
+ * Add specific attributes related to this behavior to this.anchor
+ */
+ applyAttributes: function () {},
+ /**
+ * Add specific listeners related to this behavior to this.anchor
+ */
+ applyListeners: function () {},
+ /**
+ * Disable the listeners added in @see applyListeners
+ */
+ disableListeners: function () {},
+ /**
+ * Used by @see KnowledgePlugin to remove behaviors when the field_html is
+ * saved. Also used by @see FieldHtmlInjector to manage injected behaviors
+ */
+ removeBehavior: function () {
+ this.handler.trigger_up('behavior_removed', {
+ anchor: this.anchor,
+ });
+ this.disableListeners();
+ delete this.anchor.oKnowledgeBehavior;
+ },
+});
+
+/**
+ * A behavior to set a block as uneditable. Such a block can have children
+ * marked as @see o_knowledge_content which are set as editable
+ */
+const ContentsContainerBehavior = KnowledgeBehavior.extend({
+ /**
+ * @override
+ */
+ applyAttributes: function () {
+ this._super.apply(this, arguments);
+ if (this.mode === 'edit') {
+ this.anchor.querySelectorAll('.o_knowledge_content').forEach(element => {
+ element.setAttribute('contenteditable', 'true');
+ });
+ this.anchor.setAttribute('contenteditable', 'false');
+ }
+ },
+});
+
+export {
+ KnowledgeBehavior,
+ ContentsContainerBehavior,
+};
diff --git a/addons/knowledge/static/src/js/knowledge_clipboard_whitelist.js b/addons/knowledge/static/src/js/knowledge_clipboard_whitelist.js
new file mode 100644
index 00000000000..eecb70e1a47
--- /dev/null
+++ b/addons/knowledge/static/src/js/knowledge_clipboard_whitelist.js
@@ -0,0 +1,8 @@
+/** @odoo-module */
+
+import { CLIPBOARD_WHITELISTS } from '@web_editor/../lib/odoo-editor/src/OdooEditor';
+
+/**
+ * Allow @see o_knowledge_ classes to be preserved in the editor on paste.
+ */
+CLIPBOARD_WHITELISTS.classes.push(/^o_knowledge_/);
diff --git a/addons/knowledge/static/src/js/knowledge_controller.js b/addons/knowledge/static/src/js/knowledge_controller.js
index 69d8da01f74..09a49da2b02 100644
--- a/addons/knowledge/static/src/js/knowledge_controller.js
+++ b/addons/knowledge/static/src/js/knowledge_controller.js
@@ -28,7 +28,23 @@ const KnowledgeArticleFormController = FormController.extend({
emoji_click: '_onEmojiClick',
}),
+ /**
+ * Register the fact that the current @see FormController is one from
+ * Knowledge in order not to try and search for a matching record for
+ * @see KnowledgeService .
+ *
+ * @override
+ */
init: function (parent, model, renderer, params) {
+ /**
+ * This property is used to specify that the current form view will
+ * get/use records stored in the @see KnowledgeService instead of
+ * replacing them (default). TODO ABD: maybe set this as an option of
+ * the form view (XML) so that other form views can easily specify that
+ * they will get/use records from the KnowledgeService instead of
+ * providing them.
+ */
+ this.ignoreKnowledgeRecordSearch = true;
this.renderer = renderer;
this._super.apply(this, arguments);
this.onFieldSavedListeners = new Map();
diff --git a/addons/knowledge/static/src/js/knowledge_field_html_injector.js b/addons/knowledge/static/src/js/knowledge_field_html_injector.js
new file mode 100644
index 00000000000..f1668508e03
--- /dev/null
+++ b/addons/knowledge/static/src/js/knowledge_field_html_injector.js
@@ -0,0 +1,314 @@
+/** @odoo-module */
+import Widget from 'web.Widget';
+import { ComponentWrapper, WidgetAdapterMixin } from 'web.OwlCompatibility';
+import { useService } from "@web/core/utils/hooks";
+import { TemplateToolbar, FileToolbar } from './knowledge_toolbars';
+import { ContentsContainerBehavior } from './knowledge_behaviors';
+const { Component } = owl;
+
+/**
+ * Component used to access the @see uiService
+ */
+class FieldHtmlInjectorComponent extends Component {
+ setup() {
+ this.uiService = useService('ui');
+ }
+}
+FieldHtmlInjectorComponent.template = 'knowledge.injector_component';
+/**
+ * Widget used along @see FieldHtml to inject specific @see KnowledgeBehavior
+ * and/or @see KnowledgeToolbar for specific blocks elements. These behaviors
+ * can be used in both edit and readonly modes, and will contribute to a more
+ * interactive @see FieldHtml . Toolbars added through this injector will be
+ * cleaned before save through the @see KnowledgePlugin and won't be part of
+ * the field final value.
+ */
+const FieldHtmlInjector = Widget.extend(WidgetAdapterMixin, {
+ custom_events: {
+ toolbar_removed: '_onToolbarRemoved',
+ behavior_removed: '_onBehaviorRemoved',
+ },
+ /**
+ * Map classes used in @see OdooEditor blocks to their corresponding
+ * @see KnowledgeToolbar and template.
+ */
+ toolbar_types: {
+ o_knowledge_toolbar_type_template: {
+ template: 'knowledge.template_toolbar',
+ Toolbar: TemplateToolbar,
+ },
+ o_knowledge_toolbar_type_file: {
+ template: 'knowledge.file_toolbar',
+ Toolbar: FileToolbar,
+ },
+ },
+ /**
+ * Map classes used in @see OdooEditor blocks to their corresponding
+ * @see KnowledgeBehavior
+ */
+ behavior_types: {
+ o_knowledge_behavior_type_contents_container: {
+ Behavior: ContentsContainerBehavior,
+ },
+ },
+ /**
+ * @override
+ * @param {Widget} parent
+ * @param {string} mode edit/readonly
+ * @param {Element} field content of the @see FieldHtml
+ * @param {Object} editor @see OdooEditor
+ */
+ init: function (parent, mode, field, editor) {
+ this._super.apply(this, arguments);
+ // store every toolbar anchor elements
+ this.toolbar_anchors = new Set();
+ // store every behavior anchor elements
+ this.behavior_anchors = new Set();
+ this.mode = mode;
+ this.field = field;
+ this.editor = editor;
+ },
+ /**
+ * Start the injection process and setup injection event listeners.
+ * The component is used to access uiService. This widget should be
+ * converted to a component. TODO ABD
+ * @see owl_compatibility
+ *
+ * @override
+ */
+ start: function () {
+ const prom = this._super.apply(this, arguments);
+ this.component = new ComponentWrapper(this, FieldHtmlInjectorComponent, {});
+ const componentPromise = this.component.mount(this.el);
+ $(this.field).on('refresh_injector', this._onRefreshInjector.bind(this));
+ return Promise.all([prom, componentPromise]).then(function() {
+ this.manageBehaviors();
+ return this.manageToolbars();
+ }.bind(this));
+ },
+ /**
+ * @see owl_compatibility
+ */
+ update: function () {
+ return this.component.update({});
+ },
+ /**
+ * Setup the behavior injection process
+ */
+ manageBehaviors: function() {
+ $(this.field).on('refresh_behaviors', this._onRefreshBehaviors.bind(this));
+ this.updateBehaviors();
+ },
+ /**
+ * Setup the toolbar injection process
+ *
+ * @returns {Promise} promise to append the initial batch of
+ * @see KnowledgeToolbar
+ */
+ manageToolbars: function() {
+ $(this.field).on('refresh_toolbars', this._onRefreshToolbars.bind(this));
+ return this.updateToolbars();
+ },
+ /**
+ * Scan this.field for every Toolbar container and anchor. Remove and
+ * destroy currently registered obsolete toolbars.
+ *
+ * @param {Array[Object]} toolbarsData array to be filled with data
+ * currently present in this.field
+ */
+ _scanFieldForToolbars: function (toolbarsData) {
+ const anchors = new Set();
+ const types = new Set(Object.getOwnPropertyNames(this.toolbar_types));
+ this.field.querySelectorAll('.o_knowledge_toolbars_container').forEach(function (types, container) {
+ container.querySelectorAll('.o_knowledge_toolbar_anchor').forEach(function (types, container, anchor) {
+ const type = Array.from(anchor.classList).find(className => types.has(className));
+ if (type) {
+ toolbarsData.push({
+ container: container,
+ anchor: anchor,
+ type: type,
+ });
+ anchors.add(anchor);
+ }
+ }.bind(this, types, container));
+ }.bind(this, types));
+ // difference between the stored set and the computed one
+ const differenceAnchors = new Set([...this.toolbar_anchors].filter(anchor => !anchors.has(anchor)));
+ // remove obsolete toolbars
+ differenceAnchors.forEach(anchor => {
+ if (anchor.oKnowledgeToolbar) {
+ anchor.oKnowledgeToolbar.removeToolbar();
+ }
+ });
+ },
+ /**
+ * If toolbarsData is set, update only those toolbars, if not, recompute
+ * every Toolbar of this.field
+ *
+ * @param {Array[Object]} toolbarsData
+ * @param {Element} [container] element which has a toolbar anchor
+ * @param {Element} [anchor] element in which to inject the toolbar
+ * @param {string} [type] html class representing the toolbar type
+ * (i.e.: @see o_knowledge_toolbar_type_... )
+ * @returns {Promise} promise to append toolbars
+ */
+ updateToolbars: function (toolbarsData = []) {
+ if (this.editor) {
+ this.editor.observerUnactive();
+ }
+ if (!toolbarsData.length) {
+ // no toolbarsData, recreate the array from the field value
+ this._scanFieldForToolbars(toolbarsData);
+ }
+
+ // inject new toolbars
+ const promises = [];
+ toolbarsData.forEach(toolbarData => {
+ const anchor = toolbarData.anchor;
+ if (!anchor.oKnowledgeToolbar) {
+ promises.push(this._createToolbar(toolbarData));
+ } else {
+ this.toolbar_anchors.add(anchor);
+ }
+ });
+
+ return Promise.all(promises).then(() => {
+ if (this.editor) {
+ this.editor.observerActive();
+ }
+ });
+ },
+ /**
+ * Scan this.field for every Behavior anchor. delete currently registered
+ * obsolete behaviors.
+ *
+ * @param {Array[Object]} behaviorsData array to be filled with data
+ * currently present in this.field
+ */
+ _scanFieldForBehaviors: function (behaviorsData) {
+ const anchors = new Set();
+ const types = new Set(Object.getOwnPropertyNames(this.behavior_types));
+ this.field.querySelectorAll('.o_knowledge_behavior_anchor').forEach(function (types, anchor) {
+ const type = Array.from(anchor.classList).find(className => types.has(className));
+ if (type) {
+ behaviorsData.push({
+ anchor: anchor,
+ type: type,
+ });
+ anchors.add(anchor);
+ }
+ }.bind(this, types));
+ // difference between the stored set and the computed one
+ const differenceAnchors = new Set([...this.behavior_anchors].filter(anchor => !anchors.has(anchor)));
+ // remove obsolete behaviors
+ differenceAnchors.forEach(anchor => {
+ if (anchor.oKnowledgeBehavior) {
+ anchor.oKnowledgeBehavior.removeBehavior();
+ }
+ });
+ },
+ /**
+ * If behaviorsData is set, update only those behaviors, if not, recompute
+ * every behavior of this.field
+ *
+ * @param {Array[Object]} behaviorsData
+ * @param {Element} [anchor] element to which to inject the behavior
+ * @param {string} [type] html class representing the behavior type
+ * (i.e.: @see o_knowledge_behavior_type_... )
+ */
+ updateBehaviors: function (behaviorsData = []) {
+ if (!behaviorsData.lenth) {
+ // no behaviorsData, recreate the array from the field value.
+ this._scanFieldForBehaviors(behaviorsData);
+ }
+
+ // inject new behaviors
+ behaviorsData.forEach(behaviorData => {
+ const anchor = behaviorData.anchor;
+ const {Behavior} = this.behavior_types[behaviorData.type] || {};
+ if (!Behavior) {
+ return;
+ }
+ if (!anchor.oKnowledgeBehavior) {
+ anchor.oKnowledgeBehavior = new Behavior(this, anchor, this.mode);
+ }
+ this.behavior_anchors.add(anchor);
+ });
+ },
+ /**
+ * @param {Object}
+ * @param {Element} [container] element which has a toolbar anchor
+ * @param {Element} [anchor] element in which to inject the toolbar
+ * @param {string} [type] html class representing the toolbar type
+ * (i.e.: @see o_knowledge_toolbar_type_... )
+ * @returns {Promise} promise to append this toolbar
+ */
+ _createToolbar: function ({container, anchor, type}) {
+ const {Toolbar, template} = this.toolbar_types[type] || {};
+ if (!Toolbar) {
+ return;
+ }
+ const toolbar = new Toolbar(this, container, anchor, template, this.editor, this.component.componentRef.comp.uiService);
+ anchor.oKnowledgeToolbar = toolbar;
+ this.toolbar_anchors.add(anchor);
+ const firstElementChild = anchor.firstElementChild;
+ if (firstElementChild) {
+ return toolbar.replace(firstElementChild);
+ }
+ return toolbar.appendTo(anchor);
+ },
+ /**
+ * Clean @see behavior_anchors set when a behavior is removed from the dom
+ *
+ * @param {Event} event
+ */
+ _onBehaviorRemoved: function (event) {
+ event.stopPropagation();
+ this.behavior_anchors.delete(event.data.anchor);
+ },
+ /**
+ * Clean @see toolbar_anchors set when a toolbar is removed from the dom
+ *
+ * @param {Event} event
+ */
+ _onToolbarRemoved: function (event) {
+ event.stopPropagation();
+ this.toolbar_anchors.delete(event.data.anchor);
+ },
+ /**
+ * @param {Event} e
+ * @param {Object} data
+ * @param {Array} [toolbarsDatas]
+ */
+ _onRefreshToolbars: function (e, data = {}) {
+ if (this.field) {
+ this.updateToolbars("toolbarsData" in data ? data.toolbarsData : []);
+ }
+ },
+ /**
+ * @param {Event} e
+ * @param {Object} data
+ * @param {Array} [behaviorsData]
+ */
+ _onRefreshBehaviors: function (e, data = {}) {
+ if (this.field) {
+ this.updateBehaviors("behaviorsData" in data ? data.behaviorsData : []);
+ }
+ },
+ /**
+ * @param {Event} e
+ * @param {Object} data
+ * @param {Array} [behaviorsData]
+ * @param {Array} [toolbarsDatas]
+ */
+ _onRefreshInjector: function (e, data = {}) {
+ if (this.field) {
+ this._onRefreshBehaviors(e, data);
+ this._onRefreshToolbars(e, data);
+ }
+ }
+});
+
+export {
+ FieldHtmlInjector,
+};
diff --git a/addons/knowledge/static/src/js/knowledge_macros.js b/addons/knowledge/static/src/js/knowledge_macros.js
new file mode 100644
index 00000000000..eb0478250c1
--- /dev/null
+++ b/addons/knowledge/static/src/js/knowledge_macros.js
@@ -0,0 +1,321 @@
+/** @odoo-module */
+
+import { MacroEngine } from "@web/core/macro";
+import Dialog from "web.Dialog";
+import core from "web.core";
+const _t = core._t;
+
+/**
+ * Wrapper for @see Macro destined to be used by @see KnowledgeToolbar buttons.
+ * These macros will navigate through breadcrumbs to inject information from
+ * a Knowledge article into a previously browsed record that met the
+ * requirements specified in @see FormController (Knowledge)
+ */
+class KnowledgeMacroPageChangeError extends Error {}
+export class KnowledgeMacro {
+ /**
+ * @param {Object} breadcrumbs
+ * @param {string} action name of the action to be undertaken
+ * @param {Object} data data object to be used during the action
+ * @param {Object} uiService used to cover the screen during the macro
+ * @param {integer} interval delay between the macro steps
+ */
+ constructor (breadcrumbs, action, data, uiService, interval = 16) {
+ this.breadcrumbsIndex = breadcrumbs.length - 1;
+ this.breadcrumbsTitle = breadcrumbs[this.breadcrumbsIndex].title;
+ this.breadcrumbsSelector = `[role="navigation"] > .breadcrumb-item:contains(${this.breadcrumbsTitle})`;
+ this.interval = interval;
+ this.data = data;
+ this.engine = new MacroEngine();
+ this.action = action;
+ this.uiService = uiService;
+ this.blockUI = { action: function () {
+ if (!this.uiService.isBlocked) {
+ this.uiService.block();
+ }
+ }.bind(this) };
+ this.unblockUI = { action: function () {
+ if (this.uiService.isBlocked) {
+ this.uiService.unblock();
+ }
+ }.bind(this) };
+ }
+ /**
+ * Execute the specified macro
+ */
+ start() {
+ // Build the desired macro action
+ const macroAction = this._macroActions(this.action);
+ if (!macroAction) {
+ return;
+ }
+ /**
+ * Preliminary breadcrumbs macro. It will use the @see breadcrumbsIndex
+ * to switch back to the view related to the stored record
+ * (@see KnowledgeService) . Once and if the view of the target record
+ * is correctly loaded, run the specific macroAction.
+ */
+ const startMacro = {
+ name: "restore_record",
+ interval: this.interval,
+ onError: this.onError.bind(this),
+ steps: [
+ this.blockUI, {
+ trigger: function () {
+ const $breadcrumbs = $(`.breadcrumb-item:not(.active)`);
+ if ($breadcrumbs.length > this.breadcrumbsIndex) {
+ const breadcrumb = $breadcrumbs[this.breadcrumbsIndex];
+ if (breadcrumb.textContent.includes(this.breadcrumbsTitle)) {
+ return this.getElement(breadcrumb.querySelector('a'));
+ }
+ }
+ return null;
+ }.bind(this),
+ action: 'click',
+ }, {
+ trigger: this.getElement.bind(this, `${this.breadcrumbsSelector}.active`),
+ action: this.engine.activate.bind(this.engine, macroAction),
+ }],
+ };
+ this.engine.activate(startMacro);
+ }
+ /**
+ * @see Macro
+ *
+ * @param {Error} error
+ * @param {Object} step
+ * @param {integer} index
+ */
+ onError(error, step, index) {
+ this.unblockUI.action();
+ if (error instanceof KnowledgeMacroPageChangeError) {
+ Dialog.alert(this,
+ _t('The operation was interrupted because the page or the record changed. Please try again later.'), {
+ title: _t('Error'),
+ });
+ } else {
+ console.error(error);
+ }
+ }
+ /**
+ * Return the first (or the last) matching element from selector only if it
+ * is currently visible for the user in the document.
+ *
+ * @param {string} selector
+ * @param {boolean} reverse whether to search from the end to the start
+ * @returns {Element}
+ */
+ getElement(selector, reverse=false) {
+ const $sel = $(selector);
+ for (let i = 0; i < $sel.length; i++) {
+ i = reverse ? $sel.length - 1 - i : i;
+ if ($sel.eq(i).is(':visible:hasVisibility')) {
+ return $sel[i];
+ }
+ }
+ return null;
+ }
+ /**
+ * Validate the page during the macro action. The page is valid if the
+ * targeted breadcrumb is currently active. Abort a macro in case of failure
+ * @see Macro
+ */
+ validatePage() {
+ if (!this.getElement(`${this.breadcrumbsSelector}.active`)) {
+ throw new KnowledgeMacroPageChangeError();
+ }
+ }
+ /**
+ * Build the desired action description from this.properties. Every macro
+ * will validate the page at every step and abort upon failure to do so.
+ *
+ * @param {string} action name of a handled macro action
+ * @returns {Object} valid descrption @see Macro
+ */
+ _macroActions(action) {
+ switch (action) {
+ /**
+ * @see TemplateToolbar
+ * Copy the contents of a /template block and paste into the
+ * targeted field stored in @see data . This macro is able to switch
+ * form notebook panes if the field is available in one of them.
+ */
+ case "use_as_description": return {
+ name: action,
+ interval: this.interval,
+ onError: this.onError.bind(this),
+ steps: [{
+ trigger: function () {
+ this.validatePage();
+ const selector = `.oe_form_field_html[name="${this.data.fieldName}"]`;
+ const el = this.getElement(selector);
+ if (el) {
+ return el;
+ }
+ // Handle the case where the field is hidden in a tab of the form view notebook
+ const $sel = $(selector);
+ for (let i = 0; i < $sel.length; i++) {
+ const pane = $sel[i].closest('.tab-pane:not(.active)');
+ if (pane) {
+ const paneSwitch = this.getElement(`[data-toggle="tab"][href*="${pane.id}"]`);
+ if (paneSwitch) {
+ paneSwitch.click();
+ break;
+ }
+ }
+ }
+ return null;
+ }.bind(this),
+ action: 'click',
+ }, {
+ trigger: function () {
+ this.validatePage();
+ return this.getElement(`.oe_form_field_html[name="${this.data.fieldName}"] > .odoo-editor-editable`);
+ }.bind(this),
+ action: this._pasteTemplate.bind(this),
+ }, this.unblockUI],
+ };
+ /**
+ * @see TemplateToolbar
+ * Copy the contents of a /template block and paste into a new
+ * chatter message in the targeted record context. Open the full
+ * composer to access the editor.
+ */
+ case "send_as_message": return {
+ name: action,
+ interval: this.interval,
+ onError: this.onError.bind(this),
+ steps: [{
+ trigger: function() {
+ this.validatePage();
+ return this.getElement('.o_ChatterTopbar_buttonSendMessage');
+ }.bind(this),
+ action: (el) => {
+ if (!el.classList.contains('o-active')) {
+ el.click();
+ }
+ },
+ }, {
+ trigger: function() {
+ this.validatePage();
+ return this.getElement('.o_Composer_buttonFullComposer');
+ }.bind(this),
+ action: 'click',
+ }, {
+ trigger: function () {
+ this.validatePage();
+ const dialog = this.getElement('.o_dialog_container.modal-open');
+ if (dialog) {
+ return this.getElement(dialog.querySelector('.oe_form_field_html[name="body"] > .odoo-editor-editable'));
+ } else {
+ return null;
+ }
+ }.bind(this),
+ action: this._pasteTemplate.bind(this),
+ }, this.unblockUI],
+ };
+ /**
+ * @see FileToolbar
+ * Attach a file to a new chatter message in the targeted record
+ * context.
+ */
+ case "attach_to_message": return {
+ name: action,
+ interval: this.interval,
+ onError: this.onError.bind(this),
+ steps: [{
+ trigger: function() {
+ this.validatePage();
+ return this.getElement('.o_ChatterTopbar_buttonSendMessage');
+ }.bind(this),
+ action: (el) => {
+ el.scrollIntoView();
+ if (!el.classList.contains('o-active')) {
+ el.click();
+ }
+ },
+ }, {
+ trigger: function() {
+ this.validatePage();
+ return this.getElement('.o_Composer_buttonAttachment');
+ }.bind(this),
+ action: this._dragAndDrop.bind(this, 'dragenter'),
+ }, {
+ trigger: function () {
+ this.validatePage();
+ return this.getElement('.o_Composer_dropZone');
+ }.bind(this),
+ action: this._dragAndDrop.bind(this, 'drop'),
+ }, this.unblockUI],
+ };
+ /**
+ * @see FileToolbar
+ * Attach a file to the targeted record.
+ */
+ case "use_as_attachment": return {
+ name: action,
+ interval: this.interval,
+ onError: this.onError.bind(this),
+ steps: [{
+ trigger: function() {
+ this.validatePage();
+ return this.getElement('.o_ChatterTopbar_buttonAttachments');
+ }.bind(this),
+ action: function(el) {
+ if (!this.getElement('.o_AttachmentBox_content')) {
+ el.click();
+ }
+ }.bind(this),
+ }, {
+ trigger: function() {
+ this.validatePage();
+ return this.getElement('.o_AttachmentBox_content');
+ }.bind(this),
+ action: (el) => el.scrollIntoView(),
+ }, this.unblockUI],
+ };
+ }
+ }
+ /**
+ * Handle drag&drop events with the dataTransfer object stored in @see data
+ *
+ * @param {string} type event type (related to drag&drop)
+ * @param {Element} el target element
+ */
+ _dragAndDrop(type, el) {
+ const fakeDragAndDrop = new Event(type, {
+ bubbles: true,
+ cancelable: true,
+ composed: true,
+ });
+ fakeDragAndDrop.dataTransfer = this.data.dataTransfer;
+ el.dispatchEvent(fakeDragAndDrop);
+ }
+ /**
+ * Handle paste event with the dataTransfer object stored in @see data
+ *
+ * @param {Element} el target element
+ */
+ _pasteTemplate(el) {
+ const fakePaste = new Event('paste', {
+ bubbles: true,
+ cancelable: true,
+ composed: true,
+ });
+ fakePaste.clipboardData = this.data.dataTransfer;
+
+ const sel = document.getSelection();
+ sel.removeAllRanges();
+ const range = document.createRange();
+ const firstChild = el.firstChild;
+ if (!firstChild) {
+ range.setStart(el, 0);
+ range.setEnd(el, 0);
+ } else {
+ range.setStartBefore(firstChild);
+ range.setEndBefore(firstChild);
+ }
+ sel.addRange(range);
+ el.dispatchEvent(fakePaste);
+ }
+}
diff --git a/addons/knowledge/static/src/js/knowledge_plugin.js b/addons/knowledge/static/src/js/knowledge_plugin.js
new file mode 100644
index 00000000000..c3b9e5c0d27
--- /dev/null
+++ b/addons/knowledge/static/src/js/knowledge_plugin.js
@@ -0,0 +1,26 @@
+/** @odoo-module */
+
+/**
+ * Plugin for OdooEditor. Allow to remove temporary toolbars content which are
+ * not destined to be stored in the field_html
+ */
+export class KnowledgePlugin {
+ /**
+ * @param {Element} editable
+ */
+ cleanForSave(editable) {
+ for (const node of editable.querySelectorAll('.o_knowledge_toolbar_anchor')) {
+ if (node.oKnowledgeToolbar) {
+ node.oKnowledgeToolbar.removeToolbar();
+ }
+ while (node.firstChild) {
+ node.removeChild(node.lastChild);
+ }
+ }
+ for (const node of editable.querySelectorAll('.o_knowledge_behavior_anchor')) {
+ if (node.oKnowledgeBehavior) {
+ node.oKnowledgeBehavior.removeBehavior();
+ }
+ }
+ }
+}
diff --git a/addons/knowledge/static/src/js/knowledge_service.js b/addons/knowledge/static/src/js/knowledge_service.js
new file mode 100644
index 00000000000..4cbe2a8008a
--- /dev/null
+++ b/addons/knowledge/static/src/js/knowledge_service.js
@@ -0,0 +1,151 @@
+/** @odoo-module */
+import AbstractService from 'web.AbstractService';
+import core from 'web.core';
+
+/**
+ * This service store data from non-knowledge form view records that can be used
+ * by a Knowledge form view.
+ *
+ * A typical usage could be the following:
+ * - A form view is loaded and one field of the current record is a match for
+ * Knowledge @see FormController @see FormRenderer
+ * - Information about this record and how to access its form view is stored
+ * in this @see KnowledgeService .
+ * - A knowledge Article is opened and it contains a @see TemplateToolbar .
+ * - When the toolbar is injected (@see FieldHtmlInjector ) in the view, it
+ * asks this @see KnowledgeService if the record can be interacted with.
+ * - if there is one such record, the related buttons are displayed in the
+ * toolbar.
+ * - When one such button is used, the form view of the record is reloaded
+ * @see KnowledgeMacro and the button action is executed through a @see Macro
+ * - an exemple of macro action would be copying the template contents as the
+ * value of a field_html of the record, such as "description"
+ *
+ * Scope of the service:
+ * It is meant to be called on 3 occasions:
+ * 1) by @see FormController :
+ * It will only be called if the viewed record can be used within the
+ * Knowledge module. Such a record should have a chatter in its form
+ * view, or have at least one field in a whitelist specified in the
+ * controller.
+ * 2) by @see FormRenderer :
+ * It will only be called if the viewed record has a field match in the
+ * whitelist of the controller. The renderer has to verify that the
+ * field's container is visible to the user (so that the field can be
+ * interacted with).
+ * 3) by @see KnowledgeToolbar :
+ * It will be called by a toolbar to check whether it has a record that
+ * can be interacted with in the context of the toolbar (withChatter or
+ * withHtmlField).
+ */
+const KnowledgeService = AbstractService.extend({
+ /**
+ * @override
+ */
+ start() {
+ this._super.apply(...arguments);
+ this._records = new Set();
+ this._lastVisitedRecordWithChatter = null;
+ this._lastVisitedRecordWithHtmlField = null;
+ /**
+ * Some records are added by the controller but need to be validated by
+ * the renderer. They are stored in this stack until the rendering is
+ * done.
+ */
+ this._toValidateStackWithHtmlField = [];
+ },
+ /**
+ * Called by the controller to indicate that a record need to be validated
+ * by the renderer @see FormController
+ *
+ * @param {Object} record
+ */
+ pushToValidateWithHtmlField(record) {
+ if (record && record.fieldNames.length) {
+ this._toValidateStackWithHtmlField.push(record);
+ }
+ },
+ /**
+ * Called by the renderer to validate a record @see FormRenderer
+ *
+ * @returns {Object}
+ */
+ popToValidateWithHtmlField() {
+ return this._toValidateStackWithHtmlField.pop();
+ },
+ /**
+ * Called when a record may be used by a Knowledge form view.
+ *
+ * @param {Object} record
+ */
+ registerRecord(record) {
+ if (!record) {
+ return;
+ }
+ this._records.add(record);
+ if (record.withChatter) {
+ /**
+ * if the record is flagged "withChatter", overwrite the previously
+ * registered record
+ */
+ this._lastVisitedRecordWithChatter = record;
+ }
+ if (record.withHtmlField) {
+ /**
+ * if the record is flagged "withHtmlField", reset the stack and
+ * the previously registered record
+ */
+ this._toValidateStackWithHtmlField = [];
+ this._lastVisitedRecordWithHtmlField = record;
+ }
+ },
+ /**
+ * Remove a record to signify that it can not be used by a Knowledge form
+ * view anymore.
+ *
+ * @param {Object} record
+ */
+ unregisterRecord(record) {
+ this._records.delete(record);
+ },
+ /**
+ * Recover a record that is able to interact with the chatter
+ *
+ * @returns {Object}
+ */
+ getAvailableRecordWithChatter() {
+ if (this._lastVisitedRecordWithChatter && !this._lastVisitedRecordWithChatter.withChatter) {
+ this._records.delete(this._lastVisitedRecordWithChatter);
+ }
+ if (!this._records.has(this._lastVisitedRecordWithChatter)) {
+ this._lastVisitedRecordWithChatter = null;
+ }
+ return this._lastVisitedRecordWithChatter;
+ },
+ /**
+ * Recover a record that has an available field_html
+ *
+ * @returns {Object}
+ */
+ getAvailableRecordWithHtmlField() {
+ if (this._lastVisitedRecordWithHtmlField && !this._lastVisitedRecordWithHtmlField.withHtmlField) {
+ this._records.delete(this._lastVisitedRecordWithHtmlField);
+ }
+ if (!this._records.has(this._lastVisitedRecordWithHtmlField)) {
+ this._lastVisitedRecordWithHtmlField = null;
+ }
+ return this._lastVisitedRecordWithHtmlField;
+ },
+ /**
+ * Recover a copy of the set of the currently registered records
+ *
+ * @returns {Set}
+ */
+ getRecords() {
+ return new Set(this._records);
+ },
+});
+
+core.serviceRegistry.add('knowledgeService', KnowledgeService);
+
+export default KnowledgeService;
diff --git a/addons/knowledge/static/src/js/knowledge_toolbars.js b/addons/knowledge/static/src/js/knowledge_toolbars.js
new file mode 100644
index 00000000000..09c8824aefa
--- /dev/null
+++ b/addons/knowledge/static/src/js/knowledge_toolbars.js
@@ -0,0 +1,339 @@
+/** @odoo-module */
+
+import core from 'web.core';
+import Widget from 'web.Widget';
+import Dialog from "web.Dialog";
+import utils from 'web.utils';
+import { KnowledgeMacro } from './knowledge_macros';
+import { sprintf } from '@web/core/utils/strings';
+const _t = core._t;
+
+/**
+ * Toolbar to be injected through @see FieldHtmlInjector to @see OdooEditor
+ * blocks which have specific classes calling for such toolbars.
+ *
+ * A typical usage could be the following:
+ * - An @see OdooEditor block like /template has the generic class:
+ * @see o_knowledge_toolbars_container to signify that some of its children need
+ * to have toolbars injected.
+ * - At least one of the children has the generic class:
+ * @see o_knowledge_toolbar_anchor that is a container in which the toolbar
+ * will be appended
+ * - The same child also as the specific class:
+ * @see o_knowledge_toolbar_type_[toolbarType] which specifies the type of the
+ * toolbar that needs to be injected. @see FieldHtmlInjector has a dictionary
+ * mapping those classes to the correct toolbar class.
+ *
+ * The @see KnowledgeToolbar is a basic toolbar intended to be overriden for
+ * more complex implementations
+ *
+ * This widget should be converted to a component.
+ */
+const KnowledgeToolbar = Widget.extend({
+ /**
+ * @override
+ * @param {Widget} parent
+ * @param {Element} container root node of the @see OdooEditor block, i.e.:
+ * /template or /file root container
+ * @param {Element} anchor sub-child of @see container container of the
+ * toolbar
+ * @param {string} template
+ * @param {Object} editor @see OdooEditor
+ * @param {Object} uiService when this widget is converted to a component,
+ * uiService won't be needed as a parameter
+ */
+ init: function (parent, container, anchor, template, editor, uiService) {
+ this._super.apply(this, [parent]);
+ this.container = container;
+ this.anchor = anchor;
+ this.template = template;
+ this.mode = parent.mode;
+ this.field = parent.field;
+ this.editor = editor;
+ this.uiService = uiService;
+ },
+ /**
+ * Used by @see KnowledgePlugin to remove toolbars when the field_html is
+ * saved. Also used by @see FieldHtmlInjector to manage injected toolbars
+ */
+ removeToolbar: function () {
+ this.trigger_up('toolbar_removed', {
+ anchor: this.anchor,
+ });
+ delete this.anchor.oKnowledgeToolbar;
+ this.destroy();
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ const prom = this._super.apply(this, arguments);
+ return prom.then(function () {
+ this._setupButtons();
+ }.bind(this));
+ },
+ /**
+ * Setup the toolbar buttons
+ */
+ _setupButtons: function () {
+ const buttons = this.el.querySelectorAll('button');
+ buttons.forEach(this._setupButton.bind(this));
+ },
+ // FUNCTIONS TO OVERRIDE \\
+ /**
+ * Called for each button of the toolbar. Each button should have a
+ * data-call attribute which is used as a unique key for differentiation.
+ * A common implementation would be a switch case on "button.dataset.call".
+ * Intercept dblclick events to avoid @see OdooEditor interference
+ *
+ * @param {Element} button
+ */
+ _setupButton: function (button) {
+ button.addEventListener("dblclick", function (ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ });
+ return;
+ },
+});
+
+/**
+ * Toolbar for the /file command
+ */
+const FileToolbar = KnowledgeToolbar.extend({
+ /**
+ * Recover the eventual related record from @see KnowledgeService
+ *
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this.recordWithChatter = this.call('knowledgeService', 'getAvailableRecordWithChatter');
+ },
+ /**
+ * Get data from the file related to this toolbar
+ *
+ * @param {Element} fileLink element to extract file data from
+ */
+ _fetchData: async function (fileLink) {
+ if (!fileLink || !fileLink.href) {
+ return null;
+ }
+ return await fetch(fileLink.href).then(async function (response) {
+ if (response.ok) {
+ return await response.blob();
+ } else {
+ return null;
+ }
+ });
+ },
+ /**
+ * @override
+ */
+ _setupButton: function (button) {
+ this._super.apply(this, arguments);
+ switch (button.dataset.call) {
+ /**
+ * Create a @see KnowledgeMacro to add the file to a new message
+ * in the context of the related record.
+ */
+ case 'attach_to_message':
+ button.addEventListener("click", async function(ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ const record = this.recordWithChatter;
+ if (record) {
+ const fileLink = this.container.querySelector('.o_knowledge_file_image > a');
+ const data = await this._fetchData(fileLink);
+ if (!data) {
+ return;
+ }
+ const file = new File([data], fileLink.getAttribute('title'), { type: data.type });
+ /**
+ * dataTransfer will be used to mimic a drag and drop of
+ * the file in the target record chatter.
+ * @see KnowledgeMacro
+ */
+ const dataTransfer = new DataTransfer();
+ dataTransfer.items.add(file);
+ let macro = new KnowledgeMacro(record.breadcrumbs, button.dataset.call, {
+ dataTransfer: dataTransfer,
+ }, this.uiService);
+ macro.start();
+ }
+ }.bind(this));
+ break;
+ /**
+ * Create a @see KnowledgeMacro to add the file as an attachment of
+ * the related record.
+ */
+ case 'use_as_attachment':
+ button.addEventListener("click", async function(ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ const record = this.recordWithChatter;
+ if (record) {
+ const fileLink = this.container.querySelector('.o_knowledge_file_image > a');
+ const data = await this._fetchData(fileLink);
+ if (!data) {
+ return;
+ }
+ const dataURL = await utils.getDataURLFromFile(data);
+ const attachment = await this._rpc({
+ route: '/web_editor/attachment/add_data',
+ params: {
+ 'name': fileLink.getAttribute('title'),
+ 'data': dataURL.split(',')[1],
+ 'is_image': false,
+ 'res_id': this.recordWithChatter.res_id,
+ 'res_model': this.recordWithChatter.res_model,
+ }
+ });
+ if (!attachment) {
+ return;
+ }
+ let macro = new KnowledgeMacro(record.breadcrumbs, button.dataset.call, {}, this.uiService);
+ macro.start();
+ }
+ }.bind(this));
+ break;
+ /**
+ * Add the file download behavior to the button and the image link
+ */
+ case 'download':
+ const download = async function (ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ // Roundabout way to click on the link, to avoid OdooEditor interference with the event
+ const downloadLink = document.createElement('a');
+ const originalLink = this.container.querySelector('.o_knowledge_file_image > a');
+ const title = originalLink.getAttribute('title') ? `"${originalLink.getAttribute('title')}" `: "";
+ const href = originalLink.getAttribute('href');
+ const response = await fetch(href).then((response) => response);
+ if (response.ok) {
+ downloadLink.setAttribute('href', href);
+ downloadLink.setAttribute('download', '');
+ downloadLink.setAttribute('target', '_blank');
+ downloadLink.click();
+ } else {
+ Dialog.alert(this,
+ sprintf(_t('Oops, the file %s could not be found. Please replace this file box by a new one to re-upload the file.'), title), {
+ title: _t('Missing File'),
+ buttons: [{
+ text: _t('Close'),
+ close: true,
+ }]
+ });
+ }
+ }.bind(this);
+ button.addEventListener("click", download);
+ const imageElement = this.container.querySelector('.o_knowledge_file_image');
+ if (!imageElement.oKnowledgeClickListener) {
+ imageElement.addEventListener("click", download);
+ imageElement.oKnowledgeClickListener = true;
+ }
+ break;
+ }
+ },
+});
+
+/**
+ * Toolbar for the /template command
+ */
+const TemplateToolbar = KnowledgeToolbar.extend({
+ /**
+ * Recover the eventual related records from @see KnowledgeService
+ *
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this.recordWithChatter = this.call('knowledgeService', 'getAvailableRecordWithChatter');
+ this.recordWithHtmlField = this.call('knowledgeService', 'getAvailableRecordWithHtmlField');
+ },
+ /**
+ * Create a dataTransfer object with the editable content of the template
+ * block, to be used for a paste event in the editor
+ */
+ _createHtmlDataTransfer: function () {
+ const dataTransfer = new DataTransfer();
+ const content = this.container.querySelector('.o_knowledge_content');
+ dataTransfer.setData('text/html', content.outerHTML);
+ return dataTransfer;
+ },
+ /**
+ * @override
+ */
+ _setupButton: function (button) {
+ this._super.apply(this, arguments);
+ switch (button.dataset.call) {
+ /**
+ * Create a @see KnowledgeMacro to copy the content of the /template
+ * block and paste it as the content of a new message in the context
+ * of the related record, in a fullComposer form dialog
+ */
+ case 'send_as_message':
+ button.addEventListener("click", function (ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ const record = this.recordWithChatter;
+ if (record) {
+ const dataTransfer = this._createHtmlDataTransfer();
+ let macro = new KnowledgeMacro(record.breadcrumbs, button.dataset.call, {
+ dataTransfer: dataTransfer,
+ }, this.uiService);
+ macro.start();
+ }
+ }.bind(this));
+ break;
+ /**
+ * Create a @see KnowledgeMacro to copy the content of the /template
+ * block and paste it as the content (prepend) of the field_html
+ * value of the related record
+ */
+ case 'use_as_description':
+ button.addEventListener("click", function(ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ const record = this.recordWithHtmlField;
+ if (record) {
+ const dataTransfer = this._createHtmlDataTransfer();
+ let macro = new KnowledgeMacro(record.breadcrumbs, button.dataset.call, {
+ fieldName: record.fieldNames[0].name,
+ dataTransfer: dataTransfer,
+ }, this.uiService);
+ macro.start();
+ }
+ }.bind(this));
+ break;
+ /**
+ * Copy the content of the /template block to the clipboard, and
+ * prevent @see OdooEditor interference
+ */
+ case 'copy_to_clipboard':
+ button.addEventListener("click", function (ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ });
+ const content = this.container.querySelector('.o_knowledge_content');
+ const clipboard = new ClipboardJS(
+ button,
+ {target: () => content}
+ );
+ clipboard.on('success', (e) => {
+ e.clearSelection();
+ this.displayNotification({
+ type: 'success',
+ message: _t("Template copied to clipboard."),
+ });
+ });
+ break;
+ }
+ },
+});
+
+export {
+ TemplateToolbar,
+ FileToolbar,
+ KnowledgeToolbar,
+};
diff --git a/addons/knowledge/static/src/js/knowledge_toolbars_edit.js b/addons/knowledge/static/src/js/knowledge_toolbars_edit.js
new file mode 100644
index 00000000000..7eedf3648db
--- /dev/null
+++ b/addons/knowledge/static/src/js/knowledge_toolbars_edit.js
@@ -0,0 +1,36 @@
+/** @odoo-module */
+
+import { KnowledgeToolbar } from './knowledge_toolbars';
+
+/**
+ * @see KnowledgeToolbar
+ * This override is loaded as an asset of web_editor to access editor methods
+ * and utils.
+ */
+KnowledgeToolbar.include({
+ /**
+ * set the toolbar anchor as non-editable
+ *
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ if (this.mode === 'edit') {
+ this.editor.observerUnactive('knowledge_toolbar_uneditable');
+ this.anchor.setAttribute('contenteditable', 'false');
+ this.editor.observerActive('knowledge_toolbar_uneditable');
+ }
+ },
+ /**
+ * @override
+ */
+ removeToolbar: function () {
+ if (this.mode === 'edit') {
+ this.editor.observerUnactive();
+ }
+ this._super.apply(this, arguments);
+ if (this.mode === 'edit') {
+ this.editor.observerActive();
+ }
+ },
+});
diff --git a/addons/knowledge/static/src/js/wysiwyg.js b/addons/knowledge/static/src/js/wysiwyg.js
new file mode 100644
index 00000000000..bbe2153c2f7
--- /dev/null
+++ b/addons/knowledge/static/src/js/wysiwyg.js
@@ -0,0 +1,212 @@
+odoo.define('knowledge.wysiwyg', function (require) {
+'use strict';
+
+const core = require('web.core');
+const QWeb = core.qweb;
+
+const { DocumentWidget } = require('wysiwyg.widgets.media');
+const MediaDialog = require('wysiwyg.widgets.MediaDialog');
+const Wysiwyg = require('web_editor.wysiwyg');
+const { setCursorStart } = require('@web_editor/../lib/odoo-editor/src/OdooEditor');
+
+/**
+ * Override the @see DocumentWidget to manage files in a @see MediaDialog used
+ * by the /file command. The purpose of this override is to redefine the
+ * rendering of the media (/file block), and to merge images in the documents
+ * tab of the MediaDialog, since the /file block displays a default mimetype for
+ * every files.
+ */
+const KnowledgeDocumentWidget = DocumentWidget.extend({
+ /**
+ * Filter files for the documents tab of the MediaDialog. Any file with a
+ * mimetype is valid. (images and documents are displayed together)
+ *
+ * @override
+ */
+ init: function (parent, media, options) {
+ options = _.extend({
+ accept: '*/*',
+ mimetypeDomain: [['mimetype', '!=', false]],
+ }, options || {});
+ this._super(parent, media, options);
+ },
+ /**
+ * Custom rendering for the /file command
+ *
+ * @override
+ * @param {Object} img
+ * @returns {Element}
+ */
+ _renderMedia: function (img) {
+ if (img.image_src) {
+ delete img.image_src;
+ }
+ const file = this._super(...arguments);
+ const extension = (img.name && img.name.split('.').pop()) || img.mimetype;
+ this.$media = $(QWeb.render('knowledge.file_block', {
+ img: {
+ name: img.name,
+ extension: extension,
+ },
+ }));
+ this.media = this.$media[0];
+ this.media.querySelector('.o_knowledge_file_image').replaceChildren(file);
+ return this.media;
+ }
+});
+
+/**
+ * Override the @see MediaDialog to manage documents with the
+ * @see KnowledgeDocumentWidget for the /file command
+ */
+const KnowledgeMediaDialog = MediaDialog.extend({
+ /**
+ * @override
+ * @param {Object} media
+ * @param {Object} options
+ * @returns {Widget}
+ */
+ getDocumentWidget: function (media, options) {
+ return new KnowledgeDocumentWidget(this, media, options);
+ }
+});
+
+Wysiwyg.include({
+ /**
+ * @override
+ */
+ init: function (parent, options) {
+ if (options.knowledge_commands) {
+ /**
+ * knowledge_commands is a view option from a field_html that
+ * indicates that knowledge-specific commands should be loaded.
+ * powerboxFilters is an array of functions used to filter commands
+ * displayed in the powerbox.
+ */
+ options.powerboxFilters = options.powerboxFilters ? options.powerboxFilters : [];
+ options.powerboxFilters.push(this._filterKnowledgeCommandGroupInTemplate);
+ }
+ this._super.apply(this, arguments);
+ },
+ /**
+ * Prevent usage of commands from the group "Knowledge" inside the block
+ * inserted by the /template Knowledge command. The content of a /template
+ * block is destined to be used in @see OdooEditor in modules other than
+ * Knowledge, where knowledge-specific commands may not be available.
+ * i.e.: prevent usage /template in a /template block
+ *
+ * @param {Array} commands commands available in this wysiwyg
+ * @returns {Array} commands which can be used after the filter was applied
+ */
+ _filterKnowledgeCommandGroupInTemplate: function (commands) {
+ let anchor = document.getSelection().anchorNode;
+ if (anchor.nodeType !== Node.ELEMENT_NODE) {
+ anchor = anchor.parentElement;
+ }
+ if (anchor && anchor.closest('.o_knowledge_template')) {
+ commands = commands.filter(command => command.groupName != 'Knowledge');
+ }
+ return commands;
+ },
+ /**
+ * @override
+ * @returns {Array[Object]}
+ */
+ _getCommands: function () {
+ const commands = this._super();
+ if (this.options.knowledge_commands) {
+ commands.push({
+ groupName: 'Knowledge',
+ title: 'File',
+ description: 'Embed a file.',
+ fontawesome: 'fa-file',
+ callback: () => {
+ this.openMediaDialog({
+ noVideos: true,
+ noImages: true,
+ noIcons: true,
+ noDocuments: false
+ }, KnowledgeMediaDialog);
+ }
+ }, {
+ groupName: 'Knowledge',
+ title: "Template",
+ description: "Add a template section.",
+ fontawesome: 'fa-pencil-square',
+ callback: () => {
+ this._insertTemplate();
+ },
+ });
+ }
+ return commands;
+ },
+
+ /**
+ * Notify @see FieldHtmlInjector that toolbars need to be injected
+ * @see KnowledgeToolbar
+ *
+ * @param {Element} container
+ */
+ _notifyNewToolbars(container) {
+ const toolbarsData = [];
+ container.querySelectorAll('.o_knowledge_toolbar_anchor').forEach(function (container, anchor) {
+ const type = Array.from(anchor.classList).find(className => className.startsWith('o_knowledge_toolbar_type_'));
+ if (type) {
+ toolbarsData.push({
+ container: container,
+ anchor: anchor,
+ type: type,
+ });
+ }
+ }.bind(this, container));
+ this.$editable.trigger('refresh_toolbars', { toolbarsData: toolbarsData });
+ },
+ /**
+ * Notify @see FieldHtmlInjector that behaviors need to be injected
+ * @see KnowledgeBehavior
+ *
+ * @param {Element} anchor
+ */
+ _notifyNewBehaviors(anchor) {
+ const behaviorsData = [];
+ const type = Array.from(anchor.classList).find(className => className.startsWith('o_knowledge_behavior_type_'));
+ if (type) {
+ behaviorsData.push({
+ anchor: anchor,
+ type: type,
+ });
+ }
+ this.$editable.trigger('refresh_behaviors', { behaviorsData: behaviorsData});
+ },
+ /**
+ * Insert a /template block
+ */
+ _insertTemplate() {
+ const templateFragment = new DocumentFragment();
+ const templateBlock = $(QWeb.render('knowledge.template_block', {}))[0];
+ templateFragment.append(templateBlock);
+ const [container] = this.odooEditor.execCommand('insertFragment', templateFragment);
+ setCursorStart(container.querySelector('.o_knowledge_content > p'));
+ this._notifyNewToolbars(container);
+ this._notifyNewBehaviors(container);
+ },
+ /**
+ * Notify the @see FieldHtmlInjector when a /file block is inserted
+ *
+ * @override
+ */
+ _onMediaDialogSave(params, element) {
+ const result = this._super(...arguments);
+ if (!result) {
+ return;
+ }
+ const [container] = result;
+ if (container.classList.contains('o_knowledge_file')) {
+ setCursorStart(container.nextElementSibling);
+ this._notifyNewToolbars(container);
+ this._notifyNewBehaviors(container);
+ }
+ return result;
+ },
+});
+});
diff --git a/addons/knowledge/static/src/scss/knowledge_views.scss b/addons/knowledge/static/src/scss/knowledge_views.scss
index 03ba2c9f257..0b99e8a6a77 100644
--- a/addons/knowledge/static/src/scss/knowledge_views.scss
+++ b/addons/knowledge/static/src/scss/knowledge_views.scss
@@ -271,3 +271,21 @@
}
}
}
+
+// Knowledge Toolbars
+
+.o_knowledge_toolbar {
+ display: flex;
+ justify-content: flex-end;
+ white-space: nowrap;
+ button {
+ font-family: $font-family-base !important;
+ font-weight: $font-weight-bold;
+ }
+}
+
+@media screen and (max-width: 1024px) {
+ .o_knowledge_toolbar_button_text {
+ display: none;
+ }
+}
diff --git a/addons/knowledge/static/src/xml/knowledge_editor.xml b/addons/knowledge/static/src/xml/knowledge_editor.xml
index e78dc95d10d..99bd8478568 100644
--- a/addons/knowledge/static/src/xml/knowledge_editor.xml
+++ b/addons/knowledge/static/src/xml/knowledge_editor.xml
@@ -19,17 +19,4 @@
- tag,
// we take the all content of the and avoid inserting the .
- if (fakeEl.childElementCount === 1 && fakeEl.firstChild.nodeName === 'P') {
- const p = fakeEl.firstElementChild;
- fakeEl.replaceChildren(...p.childNodes);
- } else if (fakeEl.childElementCount > 1) {
+ if (container.childElementCount === 1 && container.firstChild.nodeName === 'P') {
+ const p = container.firstElementChild;
+ container.replaceChildren(...p.childNodes);
+ } else if (container.childElementCount > 1) {
// Grab the content of the first child block and isolate it.
- if (isBlock(fakeEl.firstChild)) {
- fakeElFirstChild.replaceChildren(...fakeEl.firstElementChild.childNodes);
- fakeEl.firstElementChild.remove();
+ if (isBlock(container.firstChild)) {
+ containerFirstChild.replaceChildren(...container.firstElementChild.childNodes);
+ container.firstElementChild.remove();
}
// Grab the content of the last child block and isolate it.
- if (isBlock(fakeEl.lastChild)) {
- fakeElLastChild.replaceChildren(...fakeEl.lastElementChild.childNodes);
- fakeEl.lastElementChild.remove();
+ if (isBlock(container.lastChild)) {
+ containerLastChild.replaceChildren(...container.lastElementChild.childNodes);
+ container.lastElementChild.remove();
}
}
@@ -103,17 +121,17 @@ function insert(editor, data, isText = true) {
// first we split the current focus element if it's a block
// then we insert the content in the right places
let lastChildNode = false;
- if (fakeElLastChild.hasChildNodes()) {
+ if (containerLastChild.hasChildNodes()) {
let tempCurrentNode = currentNode;
- for (const n of [...fakeElLastChild.childNodes]) {
+ for (const n of [...containerLastChild.childNodes]) {
tempCurrentNode.after(n);
tempCurrentNode = n;
}
lastChildNode = tempCurrentNode;
}
- if (fakeElFirstChild.hasChildNodes()) {
- for (const n of [...fakeElFirstChild.childNodes]) {
+ if (containerFirstChild.hasChildNodes()) {
+ for (const n of [...containerFirstChild.childNodes]) {
currentNode.after(n);
currentNode = n;
}
@@ -121,7 +139,7 @@ function insert(editor, data, isText = true) {
// If all the Html have been isolated, We force a split of the parent element
// to have the need new line in the final result
- if (!fakeEl.hasChildNodes()) {
+ if (!container.hasChildNodes()) {
if (isUnbreakable(closestBlock(currentNode.nextSibling))) {
currentNode.nextSibling.oShiftEnter(0);
} else {
@@ -133,8 +151,8 @@ function insert(editor, data, isText = true) {
}
let nodeToInsert;
- const insertedNodes = [...fakeEl.childNodes];
- while ((nodeToInsert = fakeEl.childNodes[0])) {
+ const insertedNodes = [...container.childNodes];
+ while ((nodeToInsert = container.childNodes[0])) {
if (isBlock(nodeToInsert) && !allowsParagraphRelatedElements(currentNode)) {
// Split blocks at the edges if inserting new blocks (preventing
// text