[IMP] knowledge: add the template and file commands
This commit adds some commands that can be used in the html editor. Editor Commands --------------- Some new editor commands have been added: - */article:* This command is available on every html field using the Odoo Editor. This command allows to add a direct link to the selected knowledge article. - */file:* This command is only available in the Knowledge App. This command allows to add a file (or an image) download button inside the body. The user can choose a file to add from its own file directory or an existing attachment already present in the database. - */template:* This command is only available in the Knowledge App. This command allows the user to create a block where them can write and lay out content destined to be reused by the readers of the article. The lasts will be able to copy and paste it to send it as message or use it as a description on other records. No /command can be added inside a template. Technical notes: /file and /template commands can be activated in other modules by adding knowledge_commands = True on the html field in the view where they should be activated. Task-2674460 Part-of: odoo/odoo#82315 Co-authored-by: Damien Abeloos <abd@odoo.com> Co-authored-by: Julien Banken <jbn@odoo.com> Co-authored-by: David Beguin <dbe@odoo.com> Co-authored-by: Fabio Barbero <faba@odoo.com> Co-authored-by: Thibault Delavallee <tde@odoo.com> Co-authored-by: Aurelien Warnong <awa@odoo.com>
This commit is contained in:
committed by
David Beguin
co-authored by
Julien Banken
David Beguin
Fabio Barbero
Thibault Delavallee
Aurelien Warnong
parent
0727bbe8f1
commit
3cd87ca0dc
@@ -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',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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_/);
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 <a> 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,
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,17 +19,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-name="knowledge.wysiwyg_article_link">
|
||||
<a t-att-href="href" t-att-data-res_id="article_id"
|
||||
class="btn btn-sm o_knowledge_behavior_anchor o_knowledge_behavior_type_article">
|
||||
<t t-out="display_name"/>
|
||||
</a>
|
||||
</t>
|
||||
<t t-name="knowledge.wysiwyg_article_link_modal">
|
||||
<form class="container">
|
||||
<div class="form-group m-0">
|
||||
<input class="form-control" type="hidden" placeholder="Choose an Article"/>
|
||||
</div>
|
||||
</form>
|
||||
</t>
|
||||
</templates>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="knowledge.template_toolbar">
|
||||
<div class="o_knowledge_toolbar" contenteditable="false">
|
||||
<div class="btn-group">
|
||||
<button t-if="widget.recordWithChatter" data-call="send_as_message" title="Send as Message" class="btn btn-link btn-sm border-0">
|
||||
<i class="fa fa-envelope"/> <span class="o_knowledge_toolbar_button_text">Send as Message</span>
|
||||
</button>
|
||||
<button t-if="widget.recordWithHtmlField" data-call="use_as_description" title="Use as Description" class="btn btn-link btn-sm border-0">
|
||||
<i class="fa fa-pencil-square"/> <span class="o_knowledge_toolbar_button_text">Use as <t t-out="widget.recordWithHtmlField.fieldNames[0].string"/></span>
|
||||
</button>
|
||||
<button data-call="copy_to_clipboard" title="Copy to Clipboard" class="btn btn-link btn-sm border-0">
|
||||
<i class="fa fa-clipboard"/> <span class="o_knowledge_toolbar_button_text">Copy to Clipboard</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-name="knowledge.file_toolbar">
|
||||
<div class="o_knowledge_toolbar" contenteditable="false">
|
||||
<div class="btn-group">
|
||||
<button t-if="widget.recordWithChatter" data-call="attach_to_message" title="Send as Message" class="btn btn-link btn-sm border-0">
|
||||
<i class="fa fa-envelope"/> <span class="o_knowledge_toolbar_button_text">Send as Message</span>
|
||||
</button>
|
||||
<button t-if="widget.recordWithChatter" data-call="use_as_attachment" title="Use as Attachment" class="btn btn-link btn-sm border-0">
|
||||
<i class="fa fa-paperclip"/> <span class="o_knowledge_toolbar_button_text">Use as Attachment</span>
|
||||
</button>
|
||||
<button data-call="download" title="Download" class="btn btn-link btn-sm border-0">
|
||||
<i class="fa fa-download"/> <span class="o_knowledge_toolbar_button_text">Download</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,15 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { testEnvServices } from '@web/../tests/legacy/helpers/test_services';
|
||||
|
||||
Object.assign(testEnvServices, {
|
||||
knowledgeService: {
|
||||
pushToValidateWithHtmlField: () => {},
|
||||
popToValidateWithHtmlField: () => {},
|
||||
registerRecord: () => {},
|
||||
unregisterRecord: () => {},
|
||||
getAvailableRecordWithChatter: () => {},
|
||||
getAvailableRecordWithHtmlField: () => {},
|
||||
getRecords: () => new Set(),
|
||||
}
|
||||
});
|
||||
@@ -195,7 +195,7 @@
|
||||
<!-- Article body -->
|
||||
<div class="o_knowledge_editor d-flex flex-grow-1">
|
||||
<field name="body" widget="html" no_label="1" default_focus="1"
|
||||
options="{'resizable': False}"
|
||||
options="{'resizable': False, 'knowledge_commands': true}"
|
||||
attrs="{'readonly': ['|', ('is_locked', '=', True), ('user_has_write_access', '=', False)]}"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1140,9 +1140,11 @@ var utils = Object.assign({
|
||||
['res_model', '=', 'ir.ui.view'],
|
||||
'|',
|
||||
'|',
|
||||
'|',
|
||||
['name', '=like', '%.assets\_%.css'],
|
||||
['name', '=like', '%.assets\_%.js'],
|
||||
['name', '=like', '%.report_assets\_%.css'],
|
||||
['name', '=like', '%.assets\_%.map'],
|
||||
];
|
||||
},
|
||||
}, cookieUtils);
|
||||
|
||||
@@ -2,8 +2,8 @@ odoo.define('web.test_env', async function (require) {
|
||||
"use strict";
|
||||
|
||||
const Bus = require('web.Bus');
|
||||
const { buildQuery } = require('web.rpc');
|
||||
const session = require('web.session');
|
||||
const { makeTestEnvServices } = require('@web/../tests/legacy/helpers/test_services');
|
||||
|
||||
const { renderToString } = require('@web/core/utils/render');
|
||||
const { App, Component } = owl;
|
||||
@@ -61,25 +61,7 @@ odoo.define('web.test_env', async function (require) {
|
||||
SIZES: { XS: 0, VSM: 1, SM: 2, MD: 3, LG: 4, XL: 5, XXL: 6 },
|
||||
}, env.device),
|
||||
isDebug: env.isDebug || (() => false),
|
||||
services: Object.assign({
|
||||
ajax: {
|
||||
rpc() {
|
||||
return env.session.rpc(...arguments); // Compatibility Legacy Widgets
|
||||
},
|
||||
loadLibs() {}
|
||||
},
|
||||
getCookie() {},
|
||||
httpRequest(/* route, params = {}, readMethod = 'json' */) {
|
||||
return Promise.resolve('');
|
||||
},
|
||||
rpc(params, options) {
|
||||
const query = buildQuery(params);
|
||||
return env.session.rpc(query.route, query.params, options);
|
||||
},
|
||||
notification: { notify() { } },
|
||||
hotkey: { add: () => () => {} }, // fake service
|
||||
ui: { activeElement: document }, // fake service
|
||||
}, env.services),
|
||||
services: makeTestEnvServices(env),
|
||||
session: Object.assign({
|
||||
rpc(route, params, options) {
|
||||
if (providedRPC) {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { buildQuery } from 'web.rpc';
|
||||
|
||||
const testEnvServices = {
|
||||
getCookie() {},
|
||||
httpRequest(/* route, params = {}, readMethod = 'json' */) {
|
||||
return Promise.resolve('');
|
||||
},
|
||||
hotkey: { add: () => () => {} }, // fake service
|
||||
notification: { notify() {} },
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates services for the test environment. object
|
||||
*
|
||||
* @param {Object} [env]
|
||||
* @returns {Object}
|
||||
*/
|
||||
function makeTestEnvServices(env) {
|
||||
return Object.assign({}, testEnvServices, {
|
||||
ajax: {
|
||||
rpc() {
|
||||
return env.session.rpc(...arguments); // Compatibility Legacy Widgets
|
||||
},
|
||||
loadLibs() {}
|
||||
},
|
||||
rpc(params, options) {
|
||||
const query = buildQuery(params);
|
||||
return env.session.rpc(query.route, query.params, options);
|
||||
},
|
||||
ui: { activeElement: document }, // fake service
|
||||
}, env.services);
|
||||
}
|
||||
|
||||
export {
|
||||
makeTestEnvServices,
|
||||
testEnvServices,
|
||||
};
|
||||
@@ -1906,6 +1906,7 @@ export class OdooEditor extends EventTarget {
|
||||
editable: this.editable,
|
||||
document: this.document,
|
||||
getContextFromParentRect: this.options.getContextFromParentRect,
|
||||
commandFilters: this.options.powerboxFilters,
|
||||
_t: this.options._t,
|
||||
onShow: () => {
|
||||
this.commandbarTablePicker.hide();
|
||||
|
||||
@@ -45,7 +45,18 @@ import {
|
||||
const TEXT_CLASSES_REGEX = /\btext-[^\s]*\b/g;
|
||||
const BG_CLASSES_REGEX = /\bbg-[^\s]*\b/g;
|
||||
|
||||
function insert(editor, data, isText = true) {
|
||||
/**
|
||||
* @param {OdooEditor} editor insertion target
|
||||
* @param {string|DocumentFragment} data insertion contents
|
||||
* - MODE - | - DATA TYPE -
|
||||
* text | string
|
||||
* html | string
|
||||
* fragment | DocumentFragment
|
||||
* @param {Object} options
|
||||
* @param {string} [options.mode='text'] text | html | fragment
|
||||
* @returns {Array<Node>} inserted nodes
|
||||
*/
|
||||
function insert(editor, data, {mode = "text"}) {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
@@ -72,30 +83,37 @@ function insert(editor, data, isText = true) {
|
||||
}
|
||||
}
|
||||
|
||||
const fakeEl = document.createElement('fake-element');
|
||||
const fakeElFirstChild = document.createElement('fake-element-fc');
|
||||
const fakeElLastChild = document.createElement('fake-element-lc');
|
||||
if (isText) {
|
||||
fakeEl.innerText = data;
|
||||
} else {
|
||||
fakeEl.innerHTML = data;
|
||||
const container = document.createElement('fake-element');
|
||||
const containerFirstChild = document.createElement('fake-element-fc');
|
||||
const containerLastChild = document.createElement('fake-element-lc');
|
||||
|
||||
switch (mode) {
|
||||
case 'text':
|
||||
container.innerText = data;
|
||||
break;
|
||||
case 'html':
|
||||
container.innerHTML = data;
|
||||
break;
|
||||
case 'fragment':
|
||||
container.append(...data.childNodes);
|
||||
break;
|
||||
}
|
||||
|
||||
// In case the html inserted is all contained in a single root <P> tag,
|
||||
// we take the all content of the <p> and avoid inserting the <p>.
|
||||
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
|
||||
// <p><p>text</p></p> scenarios).
|
||||
@@ -143,8 +161,8 @@ function insert(editor, data, isText = true) {
|
||||
!allowsParagraphRelatedElements(currentNode.parentElement)
|
||||
) {
|
||||
if (isUnbreakable(currentNode.parentElement)) {
|
||||
makeContentsInline(fakeEl);
|
||||
nodeToInsert = fakeEl.childNodes[0];
|
||||
makeContentsInline(container);
|
||||
nodeToInsert = container.childNodes[0];
|
||||
break;
|
||||
}
|
||||
let offset = childNodeIndex(currentNode);
|
||||
@@ -533,11 +551,14 @@ function getTextNodes(editor) {
|
||||
// absence of a strong test suite, so the whitelist stays for now.
|
||||
export const editorCommands = {
|
||||
// Insertion
|
||||
insertHTML: (editor, data) => {
|
||||
return insert(editor, data, false);
|
||||
insertFragment: (editor, fragment) => {
|
||||
return insert(editor, fragment, {mode: 'fragment'});
|
||||
},
|
||||
insertText: (editor, data) => {
|
||||
return insert(editor, data);
|
||||
insertHTML: (editor, html) => {
|
||||
return insert(editor, html, {mode: 'html'});
|
||||
},
|
||||
insertText: (editor, text) => {
|
||||
return insert(editor, text, {mode: 'text'});
|
||||
},
|
||||
insertFontAwesome: (editor, faClass = 'fa fa-star') => {
|
||||
const insertedNode = editorCommands.insertHTML(editor, '<i></i>')[0];
|
||||
|
||||
@@ -39,6 +39,19 @@ export class Powerbox {
|
||||
}
|
||||
|
||||
render(commands) {
|
||||
if (this.options.commandFilters && this.options.commandFilters.length) {
|
||||
/**
|
||||
* Some available commands may need to be disabled in certain
|
||||
* situations. i.e.:
|
||||
* in a knowledge article, prevent the usage of the /template
|
||||
* command inside a /template block.
|
||||
* @see commandFilters is an array of functions which returns
|
||||
* commands that can be used.
|
||||
*/
|
||||
for (let filter of this.options.commandFilters) {
|
||||
commands = filter(commands);
|
||||
}
|
||||
}
|
||||
this._mainWrapperElement.innerHTML = '';
|
||||
clearTimeout(this._renderingTimeout);
|
||||
this._hoverActive = false;
|
||||
|
||||
@@ -137,6 +137,7 @@ const Wysiwyg = Widget.extend({
|
||||
autohideToolbar: !!this.options.autohideToolbar,
|
||||
isRootEditable: this.options.isRootEditable,
|
||||
placeholder: this.options.placeholder,
|
||||
powerboxFilters: this.options.powerboxFilters || [],
|
||||
controlHistoryFromDocument: this.options.controlHistoryFromDocument,
|
||||
getContentEditableAreas: this.options.getContentEditableAreas,
|
||||
defaultLinkAttributes: this.options.userGeneratedContent ? {rel: 'ugc' } : {},
|
||||
@@ -1157,8 +1158,9 @@ const Wysiwyg = Widget.extend({
|
||||
* @param {object} params
|
||||
* @param {Node} [params.node] Optionnal
|
||||
* @param {Node} [params.htmlClass] Optionnal
|
||||
* @param {Class} MediaDialog Optionnal
|
||||
*/
|
||||
openMediaDialog(params = {}) {
|
||||
openMediaDialog(params = {}, MediaDialog = weWidgets.MediaDialog) {
|
||||
const sel = this.odooEditor.document.getSelection();
|
||||
const fontawesomeIcon = getInSelection(this.odooEditor.document, '.fa');
|
||||
if (fontawesomeIcon && sel.toString().trim() === "") {
|
||||
@@ -1190,26 +1192,15 @@ const Wysiwyg = Widget.extend({
|
||||
domain: $editable.data('oe-media-domain'),
|
||||
useMediaLibrary: field && (model === 'ir.ui.view' && field === 'arch' || type === 'html'),
|
||||
}, this.options.mediaModalParams, params);
|
||||
const mediaDialog = new weWidgets.MediaDialog(this, mediaParams, $node);
|
||||
const mediaDialog = new MediaDialog(this, mediaParams, $node);
|
||||
mediaDialog.open();
|
||||
|
||||
mediaDialog.on('save', this, function (element) {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
// restore saved html classes
|
||||
if (params.htmlClass) {
|
||||
element.className += " " + params.htmlClass;
|
||||
}
|
||||
restoreSelection();
|
||||
if (wysiwygUtils.isImg($node[0]) || wasFontAwesome) {
|
||||
$node.replaceWith(element);
|
||||
this.odooEditor.unbreakableStepUnactive();
|
||||
this.odooEditor.historyStep();
|
||||
} else if (element) {
|
||||
this.odooEditor.execCommand('insertHTML', element.outerHTML);
|
||||
}
|
||||
});
|
||||
mediaDialog.on('save', this, this._onMediaDialogSave.bind(this, {
|
||||
$node: $node,
|
||||
wasFontAwesome: wasFontAwesome,
|
||||
htmlClass: params.htmlClass,
|
||||
restoreSelection: restoreSelection,
|
||||
}));
|
||||
mediaDialog.on('closed', this, function () {
|
||||
// if the mediaDialog content has been saved
|
||||
// the previous selection in not relevant anymore
|
||||
@@ -1218,6 +1209,31 @@ const Wysiwyg = Widget.extend({
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Detached function to allow overriding.
|
||||
*
|
||||
* @param {Object} params binded @see openMediaDialog
|
||||
* @param {Element} element provided by the dialog
|
||||
*/
|
||||
_onMediaDialogSave: function (params, element) {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
// restore saved html classes
|
||||
if (params.htmlClass) {
|
||||
element.className += " " + params.htmlClass;
|
||||
}
|
||||
params.restoreSelection();
|
||||
if (wysiwygUtils.isImg(params.$node[0]) || params.wasFontAwesome) {
|
||||
params.$node.replaceWith(element);
|
||||
this.odooEditor.unbreakableStepUnactive();
|
||||
this.odooEditor.historyStep();
|
||||
} else if (element) {
|
||||
const fragment = new DocumentFragment();
|
||||
fragment.append(element);
|
||||
return this.odooEditor.execCommand('insertFragment', fragment);
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
|
||||
Reference in New Issue
Block a user