init
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from lxml import etree
|
||||
|
||||
from . import controllers
|
||||
from . import models
|
||||
from . import populate
|
||||
from . import wizard
|
||||
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
def pre_init_knowledge(env):
|
||||
""" Some lxml arm64 versions cannot decode icons and cause the installation to crash.
|
||||
This will test to decode an emoji before the installation of the app, and show
|
||||
a helper message if it crashed.
|
||||
"""
|
||||
try:
|
||||
etree.fromstring("<p>😀</p>")
|
||||
except etree.XMLSyntaxError:
|
||||
raise UserError(
|
||||
"The version of the lxml package used is not supported. "
|
||||
"Consider reinstalling lxml package using 'pip install --nobinary :all: lxml'")
|
||||
|
||||
def _init_private_article_per_user(env):
|
||||
env['res.users'].search([('partner_share', '=', False)])._generate_tutorial_articles()
|
||||
@@ -0,0 +1,115 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
{
|
||||
'name': 'Knowledge',
|
||||
'summary': 'Centralize, manage, share and grow your knowledge library',
|
||||
'description': 'Centralize, manage, share and grow your knowledge library',
|
||||
'category': 'Productivity/Knowledge',
|
||||
'version': '1.0',
|
||||
'depends': [
|
||||
'web',
|
||||
'web_editor',
|
||||
'mail',
|
||||
'portal',
|
||||
'web_unsplash',
|
||||
'web_hierarchy',
|
||||
'web_map',
|
||||
],
|
||||
'data': [
|
||||
'data/article_templates.xml',
|
||||
'data/ir_config_parameter_data.xml',
|
||||
'data/ir_attachment_data.xml',
|
||||
'data/knowledge_cover_data.xml',
|
||||
'data/knowledge_article_template_category_data.xml',
|
||||
'data/knowledge_article_template_data.xml',
|
||||
'data/knowledge_article_stage_data.xml',
|
||||
'data/ir_actions_data.xml',
|
||||
'data/mail_templates.xml',
|
||||
'data/mail_templates_email_layouts.xml',
|
||||
'wizard/knowledge_invite_views.xml',
|
||||
'views/knowledge_article_views.xml',
|
||||
'views/knowledge_article_favorite_views.xml',
|
||||
'views/knowledge_article_member_views.xml',
|
||||
'views/knowledge_article_stage_views.xml',
|
||||
'views/knowledge_article_template_category_views.xml',
|
||||
'views/knowledge_templates_portal.xml',
|
||||
'views/knowledge_menus.xml',
|
||||
'views/portal_templates.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'security/ir_rule.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'license': 'OEEL-1',
|
||||
'pre_init_hook': 'pre_init_knowledge',
|
||||
'post_init_hook': '_init_private_article_per_user',
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'knowledge/static/src/scss/knowledge_common.scss',
|
||||
'knowledge/static/src/scss/knowledge_views.scss',
|
||||
'knowledge/static/src/scss/knowledge_editor.scss',
|
||||
'knowledge/static/src/scss/knowledge_blocks.scss',
|
||||
'knowledge/static/src/xml/**/*',
|
||||
('remove', 'knowledge/static/src/xml/knowledge_wysiwyg.xml'),
|
||||
'knowledge/static/src/components/**/*',
|
||||
'knowledge/static/src/discuss/**/*',
|
||||
'knowledge/static/src/emoji_picker/**/*',
|
||||
'knowledge/static/src/search_model/**/*',
|
||||
'knowledge/static/src/web/**/*',
|
||||
'knowledge/static/src/js/knowledge_controller.js',
|
||||
'knowledge/static/src/js/knowledge_plugin.js',
|
||||
'knowledge/static/src/js/knowledge_utils.js',
|
||||
'knowledge/static/src/js/knowledge_renderers.js',
|
||||
'knowledge/static/src/js/knowledge_views.js',
|
||||
'knowledge/static/src/js/tools/**/*',
|
||||
'knowledge/static/src/webclient/**/*',
|
||||
'knowledge/static/src/views/**/*',
|
||||
'knowledge/static/src/services/**/*',
|
||||
'knowledge/static/src/macros/**/*',
|
||||
],
|
||||
"web.assets_web_dark": [
|
||||
'knowledge/static/src/scss/knowledge_views.dark.scss',
|
||||
],
|
||||
'web.assets_frontend': [
|
||||
'knowledge/static/src/scss/knowledge_common.scss',
|
||||
'knowledge/static/src/scss/knowledge_blocks.scss',
|
||||
'knowledge/static/src/js/tools/**/*',
|
||||
'knowledge/static/src/js/knowledge_utils.js',
|
||||
],
|
||||
'web.assets_tests': [
|
||||
'knowledge/static/tests/tours/**/*',
|
||||
],
|
||||
'web.qunit_suite_tests': [
|
||||
'knowledge/static/tests/knowledge_form_view_tests.js',
|
||||
'knowledge/static/tests/form_controller_patch_tests.js',
|
||||
'knowledge/static/tests/knowledge_external_embeds_hotkeys.js',
|
||||
'knowledge/static/tests/knowledge_form_view_tests.js',
|
||||
'knowledge/static/tests/knowledge_behaviors_upgrade_tests.js',
|
||||
'knowledge/static/tests/knowledge_wysiwyg_collaboration_tests.js',
|
||||
('include', 'knowledge.assets_wysiwyg'),
|
||||
],
|
||||
'web.tests_assets': [
|
||||
'knowledge/static/tests/mock_services.js',
|
||||
],
|
||||
'web_editor.backend_assets_wysiwyg': [
|
||||
'knowledge/static/src/xml/knowledge_wysiwyg.xml',
|
||||
],
|
||||
'knowledge.assets_wysiwyg': [
|
||||
'knowledge/static/src/js/wysiwyg.js',
|
||||
'knowledge/static/src/js/knowledge_wysiwyg.js',
|
||||
'knowledge/static/src/xml/knowledge_editor.xml',
|
||||
'knowledge/static/src/xml/knowledge_article_templates.xml',
|
||||
'knowledge/static/src/js/knowledge_clipboard_whitelist.js',
|
||||
],
|
||||
'knowledge.webclient': [
|
||||
('include', 'web.assets_backend'),
|
||||
# knowledge webclient overrides
|
||||
'knowledge/static/src/portal_webclient/**/*',
|
||||
'web/static/src/start.js',
|
||||
],
|
||||
'knowledge.assets_knowledge_print': [
|
||||
'knowledge/static/src/scss/knowledge_print.scss',
|
||||
]
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import article_thread
|
||||
from . import main
|
||||
from . import knowledge_unsplash
|
||||
from . import portal
|
||||
@@ -0,0 +1,47 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import http
|
||||
from odoo.addons.portal.controllers.mail import MailController
|
||||
from odoo.addons.knowledge.controllers.main import KnowledgeController
|
||||
from odoo.http import request
|
||||
from odoo.addons.mail.controllers.thread import ThreadController
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
|
||||
class ArticleThreadController(KnowledgeController):
|
||||
|
||||
@http.route('/knowledge/thread/resolve', type='http', auth='user')
|
||||
def resolve_thread(self, res_id, token):
|
||||
_, thread, redirect = MailController._check_token_and_record_or_redirect('knowledge.article.thread', int(res_id), token)
|
||||
if not thread or not thread.article_id.user_can_write:
|
||||
return redirect
|
||||
if not thread.is_resolved:
|
||||
thread.is_resolved = True
|
||||
return self.redirect_to_article(thread.article_id.id, show_resolved_threads=True)
|
||||
|
||||
|
||||
class KnowledgeThreadController(ThreadController):
|
||||
|
||||
@http.route()
|
||||
def mail_thread_messages(self, thread_model, thread_id, **kwargs):
|
||||
"""Portal users doesn't have access to the mail.message model but we want them to be able to
|
||||
see the messages from a `knowledge.article.thread` on which they can access, if access rules
|
||||
applies to them.
|
||||
So for them, we check if they indeed have access to the article linked to the thread and if
|
||||
that's the case we sudo the search to return the messages.
|
||||
"""
|
||||
if request.env.user._is_portal() and thread_model == 'knowledge.article.thread':
|
||||
thread = request.env['knowledge.article.thread'].browse(thread_id).exists()
|
||||
if not thread or not thread.article_id.user_has_access:
|
||||
raise Forbidden()
|
||||
domain = [
|
||||
("res_id", "=", int(thread_id)),
|
||||
("model", "=", thread_model),
|
||||
("message_type", "=", "comment"), # only user input
|
||||
("subtype_id", "=", request.env.ref('mail.mt_comment').id), # comments in threads are sent as notes
|
||||
("is_internal", "=", False) # respect internal users only flag
|
||||
]
|
||||
res = request.env["mail.message"].sudo()._message_fetch(domain, **kwargs)
|
||||
return {**res, "messages": res["messages"].message_format()}
|
||||
return super().mail_thread_messages(thread_model, thread_id, **kwargs)
|
||||
@@ -0,0 +1,67 @@
|
||||
import requests
|
||||
import werkzeug
|
||||
|
||||
from odoo.addons.web_unsplash.controllers import main
|
||||
from odoo import http
|
||||
from odoo.exceptions import AccessError
|
||||
from odoo.http import request
|
||||
from werkzeug.urls import url_encode
|
||||
|
||||
# ID of the unsplash collection, used as a fallback for knowledge covers when we can't find a suitable image
|
||||
UNSPLASH_COLLECTION_ID = 317099
|
||||
|
||||
class KnowledgeUnsplash(main.Web_Unsplash):
|
||||
|
||||
@http.route('/knowledge/article/<model("knowledge.article"):article>/add_random_cover', type='json', auth='user')
|
||||
def add_random_cover(self, article, **kwargs):
|
||||
""" This route will try to fetch a random image from unsplash using the
|
||||
params in kwargs. If successful, the image will be saved as a knowledge
|
||||
cover, and added as cover of the article given in the params.
|
||||
"""
|
||||
|
||||
try:
|
||||
article.check_access_rights('write')
|
||||
article.check_access_rule('write')
|
||||
except AccessError:
|
||||
raise werkzeug.exceptions.Forbidden()
|
||||
|
||||
# Fetch a random image
|
||||
access_key = self._get_access_key()
|
||||
app_id = self.get_unsplash_app_id()
|
||||
# Return errors so that client knows it needs to open the CoverSelector
|
||||
# Associated values could be used in the future to adapt client behaviour wr to the error
|
||||
if not access_key or not app_id:
|
||||
return {'error': 'key_not_found'}
|
||||
kwargs['client_id'] = access_key
|
||||
|
||||
has_query = kwargs.get('query', False)
|
||||
if has_query:
|
||||
try:
|
||||
fetch_random_image_request = requests.get('https://api.unsplash.com/photos/random', params=url_encode(kwargs), timeout=5)
|
||||
except requests.exceptions.RequestException:
|
||||
return {'error': 'request_failed'}
|
||||
# If no image matched the query term, do a generic search
|
||||
if not has_query or not fetch_random_image_request.ok:
|
||||
kwargs.pop('query', None)
|
||||
kwargs['collections'] = UNSPLASH_COLLECTION_ID
|
||||
try:
|
||||
fetch_random_image_request = requests.get('https://api.unsplash.com/photos/random', params=url_encode(kwargs), timeout=5)
|
||||
except requests.exceptions.RequestException:
|
||||
return {'error': 'request_failed'}
|
||||
if not fetch_random_image_request.ok:
|
||||
return {'error': fetch_random_image_request.status_code}
|
||||
|
||||
image_info = fetch_random_image_request.json()
|
||||
|
||||
# Save image
|
||||
attachment = self.save_unsplash_url({
|
||||
image_info['id']: {
|
||||
'url': image_info['urls']['regular'],
|
||||
'download_url': image_info['links']['download_location'],
|
||||
'description': image_info['alt_description'],
|
||||
}
|
||||
}, res_model='knowledge.cover', **kwargs)[0]
|
||||
|
||||
# Create new cover using new attachment
|
||||
cover = request.env['knowledge.cover'].create({'attachment_id': attachment['id']})
|
||||
return {'cover_id': cover['id']}
|
||||
@@ -0,0 +1,291 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import werkzeug
|
||||
|
||||
from odoo import conf, http, tools, _
|
||||
from odoo.exceptions import AccessError, ValidationError
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class KnowledgeController(http.Controller):
|
||||
|
||||
# ------------------------
|
||||
# Article Access Routes
|
||||
# ------------------------
|
||||
|
||||
@http.route('/knowledge/home', type='http', auth='user')
|
||||
def access_knowledge_home(self):
|
||||
""" This route will redirect internal users to the backend view of the
|
||||
article and the share users to the frontend view instead. """
|
||||
article = request.env["knowledge.article"]._get_first_accessible_article()
|
||||
if request.env.user._is_internal():
|
||||
return self._redirect_to_backend_view(article)
|
||||
return self._redirect_to_portal_view(article)
|
||||
|
||||
@http.route('/knowledge/article/<int:article_id>', type='http', auth='user')
|
||||
def redirect_to_article(self, article_id, show_resolved_threads=False):
|
||||
""" This route will redirect internal users to the backend view of the
|
||||
article and the share users to the frontend view instead."""
|
||||
article = request.env['knowledge.article'].search([('id', '=', article_id)])
|
||||
if not article:
|
||||
return werkzeug.exceptions.Forbidden()
|
||||
|
||||
if request.env.user._is_internal():
|
||||
return self._redirect_to_backend_view(article, show_resolved_threads)
|
||||
return self._redirect_to_portal_view(article)
|
||||
|
||||
@http.route('/knowledge/article/invite/<int:member_id>/<string:invitation_hash>', type='http', auth='public')
|
||||
def article_invite(self, member_id, invitation_hash):
|
||||
""" This route will check if the given parameter allows the client to access the article via the invite token.
|
||||
Then, if the partner has not registered yet, we will redirect the client to the signup page to finally redirect
|
||||
them to the article.
|
||||
If the partner already has registrered, we redirect them directly to the article.
|
||||
"""
|
||||
member = request.env['knowledge.article.member'].sudo().browse(member_id).exists()
|
||||
correct_token = member._get_invitation_hash() if member else False
|
||||
if not correct_token or not tools.consteq(correct_token, invitation_hash):
|
||||
raise werkzeug.exceptions.NotFound()
|
||||
|
||||
partner = member.partner_id
|
||||
article = member.article_id
|
||||
|
||||
if not partner.user_ids:
|
||||
# Force the signup even if not enabled (as we explicitly invited the member).
|
||||
# They should still be able to create a user.
|
||||
signup_allowed = request.env['res.users']._get_signup_invitation_scope() == 'b2c'
|
||||
if not signup_allowed:
|
||||
partner.signup_prepare()
|
||||
partner.signup_get_auth_param()
|
||||
signup_url = partner._get_signup_url_for_action(url='/knowledge/article/%s' % article.id)[partner.id]
|
||||
return request.redirect(signup_url)
|
||||
|
||||
return request.redirect('/web/login?redirect=/knowledge/article/%s' % article.id)
|
||||
|
||||
def _redirect_to_backend_view(self, article, show_resolved_threads=False):
|
||||
return request.redirect("/web#id=%s&model=knowledge.article&action=%s&menu_id=%s" % (
|
||||
article.id if article else '',
|
||||
request.env.ref("knowledge.knowledge_article_action_form_show_resolved").id \
|
||||
if show_resolved_threads else request.env.ref("knowledge.knowledge_article_action_form").id,
|
||||
request.env.ref('knowledge.knowledge_menu_root').id
|
||||
))
|
||||
|
||||
def _redirect_to_portal_view(self, article):
|
||||
# We build the session information necessary for the web client to load
|
||||
session_info = request.env['ir.http'].session_info()
|
||||
user_context = dict(request.env.context)
|
||||
mods = conf.server_wide_modules or []
|
||||
lang = user_context.get("lang")
|
||||
cache_hashes = {
|
||||
"translations": request.env['ir.http'].get_web_translations_hash(mods, lang),
|
||||
}
|
||||
|
||||
session_info.update(
|
||||
cache_hashes=cache_hashes,
|
||||
knowledge_article_id=article.id,
|
||||
user_companies={
|
||||
'current_company': request.env.company.id,
|
||||
'allowed_companies': {
|
||||
request.env.company.id: {
|
||||
'id': request.env.company.id,
|
||||
'name': request.env.company.name,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return request.render(
|
||||
'knowledge.knowledge_portal_view',
|
||||
{'session_info': session_info},
|
||||
)
|
||||
|
||||
# ------------------------
|
||||
# Article permission panel
|
||||
# ------------------------
|
||||
|
||||
@http.route('/knowledge/get_article_permission_panel_data', type='json', auth='user')
|
||||
def get_article_permission_panel_data(self, article_id):
|
||||
"""
|
||||
Returns a dictionary containing all values required to render the permission panel.
|
||||
:param article_id: (int) article id
|
||||
"""
|
||||
article = request.env['knowledge.article'].search([('id', '=', article_id)])
|
||||
if not article:
|
||||
return werkzeug.exceptions.Forbidden()
|
||||
is_sync = not article.is_desynchronized
|
||||
# Get member permission info
|
||||
members_values = []
|
||||
members_permission = article._get_article_member_permissions(additional_fields={
|
||||
'res.partner': [
|
||||
('name', 'partner_name'),
|
||||
('email', 'partner_email'),
|
||||
('partner_share', 'partner_share'),
|
||||
],
|
||||
'knowledge.article': [
|
||||
('icon', 'based_on_icon'),
|
||||
('name', 'based_on_name'),
|
||||
],
|
||||
})[article.id]
|
||||
|
||||
based_on_articles = request.env['knowledge.article'].search([
|
||||
('id', 'in', list(set(member['based_on'] for member in members_permission.values() if member['based_on'])))
|
||||
])
|
||||
|
||||
for partner_id, member in members_permission.items():
|
||||
# empty member added by '_get_article_member_permissions', don't show it in the panel
|
||||
if not member['member_id']:
|
||||
continue
|
||||
|
||||
# if share partner and permission = none, don't show it in the permission panel.
|
||||
if member['permission'] == 'none' and member['partner_share']:
|
||||
continue
|
||||
|
||||
# if article is desyncronized, don't show members based on parent articles.
|
||||
if not is_sync and member['based_on']:
|
||||
continue
|
||||
|
||||
member_values = {
|
||||
'id': member['member_id'],
|
||||
'partner_id': partner_id,
|
||||
'partner_name': member['partner_name'],
|
||||
'partner_email': member['partner_email'],
|
||||
'permission': member['permission'],
|
||||
'based_on': f'{member["based_on_icon"] or request.env["knowledge.article"]._get_no_icon_placeholder()} {member["based_on_name"] or _("Untitled")}' if member['based_on'] else False,
|
||||
'based_on_id': member['based_on'] if member['based_on'] in based_on_articles.ids else False,
|
||||
'partner_share': member['partner_share'],
|
||||
'is_unique_writer': member['permission'] == "write" and article.inherited_permission != "write" and not any(
|
||||
other_member['permission'] == 'write'
|
||||
for partner_id, other_member in members_permission.items()
|
||||
if other_member['member_id'] != member['member_id']
|
||||
),
|
||||
}
|
||||
members_values.append(member_values)
|
||||
|
||||
internal_permission_field = request.env['knowledge.article']._fields['internal_permission']
|
||||
permission_field = request.env['knowledge.article.member']._fields['permission']
|
||||
user_is_admin = request.env.user._is_admin()
|
||||
parent_article_sudo = article.parent_id.sudo()
|
||||
inherited_permission_parent_sudo = article.inherited_permission_parent_id.sudo()
|
||||
|
||||
return {
|
||||
'internal_permission_options': sorted(internal_permission_field.get_description(request.env).get('selection', []),
|
||||
key=lambda x: x[0] == article.inherited_permission, reverse=True),
|
||||
'internal_permission': article.inherited_permission,
|
||||
'category': article.category,
|
||||
'parent_permission': parent_article_sudo.inherited_permission,
|
||||
'based_on': inherited_permission_parent_sudo.display_name,
|
||||
'based_on_id': inherited_permission_parent_sudo.id if inherited_permission_parent_sudo.user_has_access else False,
|
||||
'members_options': permission_field.get_description(request.env).get('selection', []),
|
||||
'members': members_values,
|
||||
'is_sync': is_sync,
|
||||
'parent_id': parent_article_sudo.id if parent_article_sudo.user_has_access else False,
|
||||
'parent_name': parent_article_sudo.display_name,
|
||||
'user_is_admin': user_is_admin,
|
||||
'show_admin_tip': user_is_admin and article.user_permission != 'write',
|
||||
}
|
||||
|
||||
@http.route('/knowledge/article/set_member_permission', type='json', auth='user')
|
||||
def article_set_member_permission(self, article_id, permission, member_id=False, inherited_member_id=False):
|
||||
""" Sets the permission of the given member for the given article.
|
||||
|
||||
The returned result can also include a `new_category` entry that tells the
|
||||
caller that the article changed category.
|
||||
|
||||
**Note**: The user needs "write" permission to change the permission of a user.
|
||||
|
||||
:param int article_id: target article id;
|
||||
:param string permission: permission to set on member, one of 'none',
|
||||
'read' or 'write';
|
||||
:param int member_id: id of a member of the given article;
|
||||
:param int inherited_member_id: id of a member from one of the article's
|
||||
parent (indicates rights are inherited from parents);
|
||||
"""
|
||||
article = request.env['knowledge.article'].search([('id', '=', article_id)])
|
||||
if not article:
|
||||
return werkzeug.exceptions.Forbidden()
|
||||
member = request.env['knowledge.article.member'].browse(member_id or inherited_member_id).exists()
|
||||
if not member:
|
||||
return {'error': _("The selected member does not exists or has been already deleted.")}
|
||||
|
||||
previous_category = article.category
|
||||
|
||||
try:
|
||||
article._set_member_permission(member, permission, bool(inherited_member_id))
|
||||
except (AccessError, ValidationError):
|
||||
return {'error': _("You cannot change the permission of this member.")}
|
||||
|
||||
if article.category != previous_category:
|
||||
return {'new_category': True}
|
||||
|
||||
return {}
|
||||
|
||||
@http.route('/knowledge/article/remove_member', type='json', auth='user')
|
||||
def article_remove_member(self, article_id, member_id=False, inherited_member_id=False):
|
||||
""" Removes the given member from the given article.
|
||||
|
||||
The returned result can also include a `new_category` entry that tells the
|
||||
caller that the article changed category.
|
||||
|
||||
**Note**: The user needs "write" permission to remove another member from
|
||||
the list. The user can always remove themselves from the list.
|
||||
|
||||
:param int article_id: target article id;
|
||||
:param int member_id: id of a member of the given article;
|
||||
:param int inherited_member_id: id of a member from one of the article's
|
||||
parent (indicates rights are inherited from parents);
|
||||
"""
|
||||
article = request.env['knowledge.article'].search([('id', '=', article_id)])
|
||||
if not article:
|
||||
return werkzeug.exceptions.Forbidden()
|
||||
member = request.env['knowledge.article.member'].browse(member_id or inherited_member_id).exists()
|
||||
if not member:
|
||||
return {'error': _("The selected member does not exists or has been already deleted.")}
|
||||
|
||||
previous_category = article.category
|
||||
partner = member.partner_id
|
||||
|
||||
try:
|
||||
article._remove_member(member)
|
||||
except (AccessError, ValidationError) as e:
|
||||
return {'error': e}
|
||||
|
||||
if partner == request.env.user.partner_id and article.category == 'private':
|
||||
# When leaving private article, the article will be archived instead
|
||||
# As a result, user won't see the article anymore and the home page
|
||||
# should be fully reloaded to open the first 'available' article.
|
||||
return {'reload_all': True}
|
||||
|
||||
if article.category != previous_category:
|
||||
return {'new_category': True}
|
||||
|
||||
return {}
|
||||
|
||||
@http.route('/knowledge/article/set_internal_permission', type='json', auth='user')
|
||||
def article_set_internal_permission(self, article_id, permission):
|
||||
""" Sets the internal permission of the given article.
|
||||
|
||||
The returned result can also include a `new_category` entry that tells the
|
||||
caller that the article changed category.
|
||||
|
||||
**Note**: The user needs "write" permission to update the internal permission
|
||||
of the article.
|
||||
|
||||
:param int article_id: target article id;
|
||||
:param string permission: permission to set on member, one of 'none',
|
||||
'read' or 'write';
|
||||
"""
|
||||
article = request.env['knowledge.article'].search([('id', '=', article_id)])
|
||||
if not article:
|
||||
return werkzeug.exceptions.Forbidden()
|
||||
|
||||
previous_category = article.category
|
||||
|
||||
try:
|
||||
article._set_internal_permission(permission)
|
||||
except (AccessError, ValidationError):
|
||||
return {'error': _("You cannot change the internal permission of this article.")}
|
||||
|
||||
if article.category != previous_category:
|
||||
return {'new_category': True}
|
||||
|
||||
return {}
|
||||
@@ -0,0 +1,15 @@
|
||||
from odoo.http import request
|
||||
from odoo.addons.portal.controllers import portal
|
||||
|
||||
|
||||
class KnowledgePortal(portal.CustomerPortal):
|
||||
|
||||
def _prepare_home_portal_values(self, counters):
|
||||
values = super()._prepare_home_portal_values(counters)
|
||||
if 'knowledge_count' in counters:
|
||||
values['knowledge_count'] = request.env['knowledge.article'].search_count(self._prepare_knowledge_article_domain())
|
||||
return values
|
||||
|
||||
def _prepare_knowledge_article_domain(self):
|
||||
"""Generate the domain for the portal's articles"""
|
||||
return []
|
||||
@@ -0,0 +1,354 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
|
||||
<template id="knowledge_article_user_onboarding">
|
||||
<h1>
|
||||
<span style="font-size: 36px;">Hello there <t t-out="object.name"/>,</span>
|
||||
</h1>
|
||||
<p>
|
||||
<span style="font-size: 14px;">
|
||||
This is where you and your team can centralize your
|
||||
<font class="text-o-color-2" style="font-weight: bolder;">Knowledge</font>
|
||||
and best practices! 🚀
|
||||
</span>
|
||||
</p>
|
||||
<p><br /></p>
|
||||
<h3>Diving in</h3>
|
||||
<hr />
|
||||
<p>
|
||||
<span style="font-size: 14px;">This private page is for you to play around with.</span>
|
||||
<br />
|
||||
<span style="font-size: 14px;">Ready to give it a spin?</span>
|
||||
</p>
|
||||
<p>
|
||||
<span style="font-size: 14px;">Try the following 👇</span>
|
||||
</p>
|
||||
<ul class="o_checklist">
|
||||
<li id="checkId-140908257281">
|
||||
<span style="font-size: 14px;">Click anywhere, and just start typing</span>
|
||||
</li>
|
||||
<li id="checkId-441216037148">
|
||||
<span style="font-size: 14px;">Press Ctrl-Z to undo any change</span>
|
||||
</li>
|
||||
<li id="checkId-948702291173">
|
||||
<span style="font-size: 14px;">Check this box to indicate it's done</span>
|
||||
</li>
|
||||
<li id="checkId-980463482772">
|
||||
<span style="font-size: 14px;">
|
||||
Select text to
|
||||
<font class="bg-o-color-2">Highlight</font>,
|
||||
<span style="text-decoration-line: line-through;">strikethrough</span>
|
||||
or
|
||||
<span style="font-weight: bolder;">style</span>
|
||||
<span style="font-style: italic; text-decoration-line: underline;">it</span>
|
||||
</span>
|
||||
</li>
|
||||
<li id="checkId-152369821198">
|
||||
<span style="font-size: 14px;">
|
||||
Below this list, try
|
||||
<span style="font-weight: bolder;">commands</span>
|
||||
by
|
||||
<span style="font-weight: bolder;">
|
||||
<font class="text-o-color-2">typing</font>
|
||||
</span>
|
||||
"<span style="font-weight: bolder;">/</span>"
|
||||
</span>
|
||||
</li>
|
||||
<li class="oe-nested" id="checkId-947916154443">
|
||||
<ul class="o_checklist">
|
||||
<li id="checkId-1422510558483">
|
||||
<span style="font-size: 14px;">
|
||||
Add a checklist
|
||||
(/<span style="font-style: italic;">checklist</span>)
|
||||
</span>
|
||||
</li>
|
||||
<li id="checkId-109605733655">
|
||||
<span style="font-size: 14px;">
|
||||
Add a separator
|
||||
(/<span style="font-style: italic;">separator</span>)
|
||||
</span>
|
||||
</li>
|
||||
<li id="checkId-1518315885641">
|
||||
<span style="font-size: 14px;">
|
||||
Use
|
||||
/<span style="font-style: italic;">heading</span>
|
||||
to convert a text into a title
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<p><br /></p>
|
||||
<p>
|
||||
<span style="font-size: 14px;">Got it? Now let's try advanced features 🧠</span>
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<span style="font-size: 14px;">
|
||||
Use
|
||||
/<span style="font-weight: bolder;"><font class="text-o-color-2">clipboard</font></span>
|
||||
to insert a
|
||||
<span style="font-weight: bolder;">
|
||||
<font class="text-o-color-2">clipboard</font>
|
||||
</span>
|
||||
box. Need to re-use its content?
|
||||
</span>
|
||||
</li>
|
||||
<li class="oe-nested">
|
||||
<ul>
|
||||
<li>
|
||||
<span style="font-size: 14px;">From any Odoo document, find this article by clicking on the 📗 icon in the chatter.</span>
|
||||
</li>
|
||||
<li>
|
||||
<span style="font-size: 14px;">You can use the clipboard as a description, a message or simply copy it to your clipboard! 👌</span>
|
||||
</li>
|
||||
<li>
|
||||
<span style="font-size: 14px;">Not sure how to do it? <a href="https://www.youtube.com/watch?v=r_r8rl73asE">See how it works <i class="fa fa-youtube-play"/></a>.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="o_knowledge_behavior_anchor o_knowledge_behavior_type_template" data-oe-protected="true">
|
||||
<div class="d-flex">
|
||||
<div class="o_knowledge_template_label align-middle">Clipboard</div>
|
||||
</div>
|
||||
<div class="o_knowledge_content" data-prop-name="content" data-oe-protected="false">
|
||||
<p>
|
||||
Hello there, I am a template 👋
|
||||
<br />
|
||||
Use the buttons at the top-right of this box to re-use my content.
|
||||
<br />
|
||||
No more time wasted! 🔥
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p><br /></p>
|
||||
<ul>
|
||||
<li>
|
||||
<span style="font-size: 14px;">
|
||||
Use
|
||||
/<span style="font-weight: bolder;"><font class="text-o-color-2">file</font></span>
|
||||
to share documents that are frequently needed
|
||||
</span>
|
||||
</li>
|
||||
<li class="oe-nested">
|
||||
<ul>
|
||||
<li>
|
||||
<span style="font-size: 14px;">Need this document somewhere? Come back here by clicking on the 📗 icon in the chatter.</span>
|
||||
</li>
|
||||
<li>
|
||||
<span style="font-size: 14px;">From this box, files can be previewed, forwarded and downloaded. 📁</span>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="o_knowledge_behavior_anchor o_knowledge_behavior_type_file" data-oe-protected="true">
|
||||
<div class="o_knowledge_file_image" data-prop-name="fileImage">
|
||||
<a href="/knowledge/static/src/demo/Onboarding.pdf?download=true" title="Onboarding.pdf" data-mimetype="application/pdf" class="o_image"></a>
|
||||
</div>
|
||||
<div class="o_knowledge_file_description">
|
||||
<div class="o_knowledge_file_name" data-prop-name="fileName">Odoo Survival Guide</div>
|
||||
<div class="o_knowledge_file_extension" data-prop-name="fileExtension">pdf</div>
|
||||
</div>
|
||||
</div>
|
||||
<p><br /></p>
|
||||
<p>
|
||||
<span style="font-size: 14px;">
|
||||
Want to go
|
||||
<span style="font-style: italic;">even</span>
|
||||
faster? ⚡️
|
||||
</span>
|
||||
<br />
|
||||
<span style="font-size: 14px;">
|
||||
Access
|
||||
<span style="font-weight: bolder;">
|
||||
<font class="text-o-color-2">Articles</font>
|
||||
</span>
|
||||
by opening the
|
||||
<span style="font-weight: bolder;">
|
||||
<font class="text-o-color-2">Command Palette</font>
|
||||
</span>
|
||||
(Ctrl + k/⌘ + k) then search through articles by starting your query with
|
||||
"<span style="font-weight: bolder;">?</span>".
|
||||
</span>
|
||||
</p>
|
||||
<p><br /></p>
|
||||
<h3>Navigation Basics 🐣</h3>
|
||||
<hr />
|
||||
<p>
|
||||
<span style="font-size: 14px;">
|
||||
👈 See the
|
||||
<span style="font-weight: bolder;">
|
||||
<font class="text-o-color-2">Menu</font>
|
||||
</span>
|
||||
there, on the left?
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<span style="font-size: 14px;">
|
||||
Those are your
|
||||
<span style="font-weight: bolder;"><font class="text-o-color-2">Articles</font></span>.
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<span style="font-size: 14px;">Each of them can be used both as:</span>
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<span style="font-size: 14px;">
|
||||
Content — Just click and
|
||||
<span style="font-weight: bolder;">
|
||||
<font class="text-o-color-2">start typing</font>
|
||||
</span>
|
||||
(<span style="font-style: italic;">documentation, tips, reports, ...</span>)
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span style="font-size: 14px;">
|
||||
Folders —
|
||||
</span>
|
||||
<span style="font-size: 14px;">
|
||||
<span style="font-weight: bolder;">
|
||||
<font class="text-o-color-2">Nest other Articles</font>
|
||||
</span>
|
||||
under it to regroup them
|
||||
(<span style="font-style: italic;">per team, topic, project, ...</span>)
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
<span style="font-size: 14px;">
|
||||
To change the way
|
||||
<span style="font-weight: bolder;">
|
||||
<font class="text-o-color-2">Articles</font>
|
||||
</span>
|
||||
are organized, you can simply
|
||||
<span style="font-weight: bolder;">
|
||||
<font class="text-o-color-2">Drag & Drop</font>
|
||||
</span>
|
||||
them
|
||||
</span>
|
||||
</p>
|
||||
<p><br /></p>
|
||||
<h3>Who has access to what? 🕵️</h3>
|
||||
<hr />
|
||||
<p>
|
||||
<span style="font-size: 14px;">
|
||||
<span style="font-weight: bolder;">
|
||||
<font class="text-o-color-2">Articles</font>
|
||||
</span>
|
||||
are stored into different
|
||||
<span style="font-weight: bolder;"><font class="text-o-color-2">Sections</font></span>:
|
||||
</span>
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<span style="font-size: 14px;">
|
||||
<span style="font-weight: bolder;">
|
||||
<font class="text-o-color-2">Favorites</font>
|
||||
</span>
|
||||
— Those are
|
||||
<span style="font-weight: bolder;">
|
||||
<font class="text-o-color-2">shortcuts</font>
|
||||
</span>
|
||||
you create for yourself.
|
||||
Unstar ⭐ this page at the top to remove it from your favorites.
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span style="font-size: 14px;">
|
||||
<span style="font-weight: bolder;">
|
||||
<font class="text-o-color-2">Workspace</font>
|
||||
</span>
|
||||
</span>
|
||||
<span style="font-size: 14px;">
|
||||
— Articles there can be accessed by
|
||||
<span style="font-weight: bolder;">
|
||||
<font class="text-o-color-2">your team</font>
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span style="font-size: 14px;">
|
||||
<span style="font-weight: bolder;">
|
||||
<font class="text-o-color-2">Shared</font>
|
||||
</span>
|
||||
</span>
|
||||
<span style="font-size: 14px;">
|
||||
— Those are the ones you
|
||||
<span style="font-weight: bolder;">
|
||||
<font class="text-o-color-2">have invited someone or been invited to</font>
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span style="font-size: 14px;">
|
||||
<span style="font-weight: bolder;">
|
||||
<font class="text-o-color-2">Private</font>
|
||||
</span>
|
||||
</span>
|
||||
<span style="font-size: 14px;">
|
||||
— This is
|
||||
<span style="font-weight: bolder;"><font class="text-o-color-2">your stuff</font></span>,
|
||||
the things you keep for yourself
|
||||
(<span style="font-style: italic;">Drafts, Todo lists, ...</span>)
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
<span style="font-size: 14px;">
|
||||
And again, to move an
|
||||
<span style="font-weight: bolder;">
|
||||
<font class="text-o-color-2">Article</font>
|
||||
</span>
|
||||
from a
|
||||
<span style="font-weight: bolder;">
|
||||
<font class="text-o-color-2">Section</font>
|
||||
</span>
|
||||
to another, just
|
||||
<span style="font-weight: bolder;">
|
||||
<font class="text-o-color-2">Drag & Drop</font>
|
||||
</span>
|
||||
it.
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<span style="font-size: 14px;">
|
||||
A good workflow is to write your drafts in
|
||||
<span style="font-weight: bolder;">
|
||||
<font class="text-o-color-2">Private</font>
|
||||
</span>
|
||||
and, once done, move it from
|
||||
<span style="font-weight: bolder;">
|
||||
<font class="text-o-color-2">Private</font>
|
||||
</span>
|
||||
to
|
||||
<span style="font-weight: bolder;">
|
||||
<font class="text-o-color-2">Workspace</font>
|
||||
</span>
|
||||
to share it with everyone.
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<span style="font-size: 14px;">And voilà, it is that simple.</span>
|
||||
</p>
|
||||
<p>
|
||||
<span style="font-size: 48px;">🚀</span>
|
||||
</p>
|
||||
<p><br /></p>
|
||||
<hr />
|
||||
<p>
|
||||
To be sure to stay updated, follow us on
|
||||
<a href="https://twitter.com/Odoo" target="_blank"><span style="font-weight: bolder;"><font class="text-o-color-1">Twitter</font></span></a>,
|
||||
<a href="https://www.youtube.com/channel/UCkQPikELWZFLgQNHd73jkdg" target="_blank">
|
||||
<span style="font-weight: bolder;">
|
||||
<font class="text-o-color-1">YouTube</font>
|
||||
</span>
|
||||
</a>
|
||||
or
|
||||
<a href="https://www.facebook.com/odoo" target="_blank"><span style="font-weight: bolder;"><font class="text-o-color-1">Facebook</font></span></a>.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
</data></odoo>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo><data noupdate="0">
|
||||
|
||||
<record id="ir_actions_server_knowledge_home_page" model="ir.actions.server">
|
||||
<field name="name">Articles</field>
|
||||
<field name="model_id" ref="model_knowledge_article"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">action = model.action_home_page()</field>
|
||||
</record>
|
||||
|
||||
</data></odoo>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
<!-- Attachments -->
|
||||
<record id="ir_attachment_todo_list" model="ir.attachment">
|
||||
<field name="name">Todo.jpg</field>
|
||||
<field name="res_model">knowledge.cover</field>
|
||||
<field name="res_field">attachment_id</field>
|
||||
<field name="public">True</field>
|
||||
<field name="type">url</field>
|
||||
<field name="url">/knowledge/static/src/img/todo.jpg</field>
|
||||
</record>
|
||||
|
||||
<record id="ir_attachment_house" model="ir.attachment">
|
||||
<field name="name">House.jpg</field>
|
||||
<field name="res_model">knowledge.cover</field>
|
||||
<field name="res_field">attachment_id</field>
|
||||
<field name="public">True</field>
|
||||
<field name="type">url</field>
|
||||
<field name="url">/knowledge/static/src/img/house.jpg</field>
|
||||
</record>
|
||||
|
||||
<record id="ir_attachment_persona_1" model="ir.attachment">
|
||||
<field name="name">Persona_1.jpg</field>
|
||||
<field name="res_model">knowledge.cover</field>
|
||||
<field name="res_field">attachment_id</field>
|
||||
<field name="public">True</field>
|
||||
<field name="type">url</field>
|
||||
<field name="url">/knowledge/static/src/img/persona_1.jpg</field>
|
||||
</record>
|
||||
|
||||
<record id="ir_attachment_persona_2" model="ir.attachment">
|
||||
<field name="name">Persona_2.jpg</field>
|
||||
<field name="res_model">knowledge.cover</field>
|
||||
<field name="res_field">attachment_id</field>
|
||||
<field name="public">True</field>
|
||||
<field name="type">url</field>
|
||||
<field name="url">/knowledge/static/src/img/persona_2.jpg</field>
|
||||
</record>
|
||||
|
||||
<record id="ir_attachment_persona_3" model="ir.attachment">
|
||||
<field name="name">Persona_3.jpg</field>
|
||||
<field name="res_model">knowledge.cover</field>
|
||||
<field name="res_field">attachment_id</field>
|
||||
<field name="public">True</field>
|
||||
<field name="type">url</field>
|
||||
<field name="url">/knowledge/static/src/img/persona_3.jpg</field>
|
||||
</record>
|
||||
|
||||
<record id="ir_attachment_persona_4" model="ir.attachment">
|
||||
<field name="name">Persona_4.jpg</field>
|
||||
<field name="res_model">knowledge.cover</field>
|
||||
<field name="res_field">attachment_id</field>
|
||||
<field name="public">True</field>
|
||||
<field name="type">url</field>
|
||||
<field name="url">/knowledge/static/src/img/persona_4.jpg</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<function model="ir.config_parameter" name="set_param" eval="('knowledge.knowledge_article_trash_limit_days', '30')"/>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
<!-- Stages -->
|
||||
<record id="knowledge_article_template_stage_new" model="knowledge.article.stage">
|
||||
<field name="name">New</field>
|
||||
<field name="sequence">1</field>
|
||||
<field name="fold">False</field>
|
||||
<field name="parent_id" ref="knowledge_article_template_shared_todos"/>
|
||||
</record>
|
||||
|
||||
<record id="knowledge_article_template_stage_ongoing" model="knowledge.article.stage">
|
||||
<field name="name">Ongoing</field>
|
||||
<field name="sequence">2</field>
|
||||
<field name="fold">False</field>
|
||||
<field name="parent_id" ref="knowledge_article_template_shared_todos"/>
|
||||
</record>
|
||||
|
||||
<record id="knowledge_article_template_stage_done" model="knowledge.article.stage">
|
||||
<field name="name">Done</field>
|
||||
<field name="sequence">3</field>
|
||||
<field name="fold">True</field>
|
||||
<field name="parent_id" ref="knowledge_article_template_shared_todos"/>
|
||||
</record>
|
||||
|
||||
<!-- Assign a stage to the articles items -->
|
||||
|
||||
<record id="knowledge_article_template_shared_todos_water_the_plants" model="knowledge.article">
|
||||
<field name="stage_id" ref="knowledge_article_template_stage_new"/>
|
||||
</record>
|
||||
|
||||
<record id="knowledge_article_template_shared_todos_pay_the_electricity_bill" model="knowledge.article">
|
||||
<field name="stage_id" ref="knowledge_article_template_stage_new"/>
|
||||
</record>
|
||||
|
||||
<record id="knowledge_article_template_shared_todos_write_the_next_newsletter" model="knowledge.article">
|
||||
<field name="stage_id" ref="knowledge_article_template_stage_ongoing"/>
|
||||
</record>
|
||||
|
||||
<record id="knowledge_article_template_shared_todos_contact_our_lawyer" model="knowledge.article">
|
||||
<field name="stage_id" ref="knowledge_article_template_stage_done"/>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
<!-- Categories -->
|
||||
<record model="knowledge.article.template.category" id="knowledge_article_template_category_productivity">
|
||||
<field name="name">Productivity</field>
|
||||
<field name="sequence">100</field>
|
||||
</record>
|
||||
|
||||
<record model="knowledge.article.template.category" id="knowledge_article_template_category_sales">
|
||||
<field name="name">Sales</field>
|
||||
<field name="sequence">200</field>
|
||||
</record>
|
||||
|
||||
<record model="knowledge.article.template.category" id="knowledge_article_template_category_marketing">
|
||||
<field name="name">Marketing</field>
|
||||
<field name="sequence">300</field>
|
||||
</record>
|
||||
|
||||
<record model="knowledge.article.template.category" id="knowledge_article_template_category_company_organization">
|
||||
<field name="name">Company Organization</field>
|
||||
<field name="sequence">400</field>
|
||||
</record>
|
||||
|
||||
<record model="knowledge.article.template.category" id="knowledge_article_template_category_product_management">
|
||||
<field name="name">Product Management</field>
|
||||
<field name="sequence">500</field>
|
||||
</record>
|
||||
</odoo>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
<!-- Attachments -->
|
||||
<record id="ir_attachment_computer_monitor_with_code" model="ir.attachment">
|
||||
<field name="name">computer_monitor_with_code.jpg</field>
|
||||
<field name="res_model">knowledge.cover</field>
|
||||
<field name="res_field">attachment_id</field>
|
||||
<field name="public">True</field>
|
||||
<field name="type">url</field>
|
||||
<field name="url">/knowledge/static/src/img/computer_monitor_with_code.jpg</field>
|
||||
</record>
|
||||
|
||||
<!-- Covers -->
|
||||
<record id="knowledge_cover_release_note" model="knowledge.cover">
|
||||
<field name="attachment_id" ref="ir_attachment_computer_monitor_with_code"/>
|
||||
</record>
|
||||
|
||||
<record id="knowledge_cover_todo_list" model="knowledge.cover">
|
||||
<field name="attachment_id" ref="ir_attachment_todo_list"/>
|
||||
</record>
|
||||
|
||||
<record id="knowledge_cover_house" model="knowledge.cover">
|
||||
<field name="attachment_id" ref="ir_attachment_house"/>
|
||||
</record>
|
||||
|
||||
<record id="knowledge_cover_persona_1" model="knowledge.cover">
|
||||
<field name="attachment_id" ref="ir_attachment_persona_1"/>
|
||||
</record>
|
||||
|
||||
<record id="knowledge_cover_persona_2" model="knowledge.cover">
|
||||
<field name="attachment_id" ref="ir_attachment_persona_2"/>
|
||||
</record>
|
||||
|
||||
<record id="knowledge_cover_persona_3" model="knowledge.cover">
|
||||
<field name="attachment_id" ref="ir_attachment_persona_3"/>
|
||||
</record>
|
||||
|
||||
<record id="knowledge_cover_persona_4" model="knowledge.cover">
|
||||
<field name="attachment_id" ref="ir_attachment_persona_4"/>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
|
||||
<template id="knowledge_article_mail_invite">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: white; padding: 0; border-collapse:separate;">
|
||||
<tr><td valign="top">
|
||||
<div style="margin: 0px; padding: 0px; font-size: 13px;">
|
||||
<p style="margin: 0px; padding: 0px; font-size: 13px;">
|
||||
<t t-if="record.name">
|
||||
<t t-out="user.name or ''"/> invited you to <t t-if="permission == 'write'">EDIT</t><t t-if="permission == 'read'">READ</t> the article <t t-out="record.name"/>.<br/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-out="user.name or ''"/> invited you to access an article.<br/>
|
||||
</t>
|
||||
<div class="text-muted">
|
||||
<t t-if="message" t-out="message"/>
|
||||
<t t-else=""><br/></t>
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
</td></tr>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<template id="knowledge_article_trash_notification">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: white; padding: 0; border-collapse:separate;">
|
||||
<tr><td valign="top">
|
||||
<div style="margin: 0px; padding: 0px; font-size: 13px;">
|
||||
<p style="margin: 0px; padding: 0px; font-size: 13px;">
|
||||
<p>Dear <t t-out="recipient.name"/>,</p>
|
||||
<t t-foreach="articles" t-as="article">
|
||||
<a t-att-href="article.article_url"><t t-out="article.name"/></a><t t-if="article_last">,</t>
|
||||
</t>
|
||||
<t t-if="child_articles">and the following child article(s) have</t><t t-elif="len(articles) > 1">have</t><t t-else="">has</t>
|
||||
been sent to Trash.<br/><br/>
|
||||
<ul t-if="child_articles">
|
||||
<li t-foreach="child_articles" t-as="article">
|
||||
<a t-att-href="article.article_url"><t t-out="article.name"/></a>
|
||||
</li>
|
||||
</ul>
|
||||
</p>
|
||||
</div>
|
||||
</td></tr>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<!-- This mail template is only sent to users that are mentioned in the composer of the comments
|
||||
because it is configured in 'note' mode. This means that each comment added on a thread is a note logged
|
||||
and does not trigger the sending of a notification unless some people are tagged. The only way to receive all
|
||||
the note notification via email would be to modify the subtypes subscription inside the
|
||||
knowledge.article.thread model, which is not easily accessible.-->
|
||||
<template id="knowledge_mail_notification_layout" inherit_id="mail.mail_notification_layout" primary="True">
|
||||
<xpath expr="//div[@t-out='message.body']" position="replace">
|
||||
<span style="margin-bottom: 16px; font-size: 13px;"><t t-out="message.author_id.name"/> mentioned you in a comment:</span>
|
||||
<table style="padding-top: 16px">
|
||||
<tr style="padding-bottom: 0px; padding-top: 16px">
|
||||
<td>
|
||||
<div style="font-size: small; font-weight:bolder; padding-left: 8px;" t-out="message.author_id.name"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="margin-left: 8px; padding-left: 8px; padding-right: 8px;">
|
||||
<div t-out="message.body"/>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</xpath>
|
||||
</template>
|
||||
</data>
|
||||
</odoo>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import knowledge_article_thread
|
||||
from . import knowledge_article_favorite
|
||||
from . import knowledge_article_member
|
||||
from . import knowledge_article_template_category
|
||||
from . import knowledge_article
|
||||
from . import knowledge_article_stage
|
||||
from . import knowledge_cover
|
||||
from . import res_partner
|
||||
from . import res_users
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,64 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, exceptions, fields, models, _
|
||||
|
||||
|
||||
class ArticleFavorite(models.Model):
|
||||
_name = 'knowledge.article.favorite'
|
||||
_description = 'Favorite Article'
|
||||
_order = 'sequence ASC, id DESC'
|
||||
_rec_name = 'article_id'
|
||||
|
||||
article_id = fields.Many2one(
|
||||
'knowledge.article', 'Article',
|
||||
index=True, required=True, ondelete='cascade')
|
||||
user_id = fields.Many2one(
|
||||
'res.users', 'User',
|
||||
index=True, required=True, ondelete='cascade')
|
||||
is_article_active = fields.Boolean('Is Article Active', related='article_id.active',
|
||||
store=True, readonly=True)
|
||||
sequence = fields.Integer(default=0)
|
||||
|
||||
_sql_constraints = [
|
||||
('unique_article_user',
|
||||
'unique(article_id, user_id)',
|
||||
'User already has this article in favorites.')
|
||||
]
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
""" At creation, we need to set the max sequence, if not given, for each favorite to create, in order to keep
|
||||
a correct ordering as much as possible. Some sequence could be given in create values, that could lead to
|
||||
duplicated sequence per user_id. That is not an issue as they will be resequenced the next time the user reorder
|
||||
their favorites. """
|
||||
# TDE TODO: env.uid -> user_id
|
||||
default_sequence = 1
|
||||
if any(not vals.get('sequence') for vals in vals_list):
|
||||
favorite = self.env['knowledge.article.favorite'].search(
|
||||
[('user_id', '=', self.env.uid)],
|
||||
order='sequence DESC',
|
||||
limit=1
|
||||
)
|
||||
default_sequence = favorite.sequence + 1 if favorite else default_sequence
|
||||
for vals in vals_list:
|
||||
if not vals.get('sequence'):
|
||||
vals['sequence'] = default_sequence
|
||||
default_sequence += 1
|
||||
return super(ArticleFavorite, self).create(vals_list)
|
||||
|
||||
def write(self, vals):
|
||||
""" Whatever rights, avoid any attempt at privilege escalation. """
|
||||
if ('article_id' in vals or 'user_id' in vals) and not self.env.is_admin():
|
||||
raise exceptions.AccessError(_("Can not update the article or user of a favorite."))
|
||||
return super().write(vals)
|
||||
|
||||
def resequence_favorites(self, article_ids):
|
||||
# Some article may not be accessible by the user anymore. Therefore,
|
||||
# to prevent an access error, one will only resequence the favorites
|
||||
# related to the articles accessible by the user
|
||||
sequence = 0
|
||||
# Keep the same order as in article_ids
|
||||
for article_id in article_ids:
|
||||
self.search([('article_id', '=', article_id), ('user_id', '=', self.env.uid)]).write({"sequence": sequence})
|
||||
sequence += 1
|
||||
@@ -0,0 +1,97 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, tools, _
|
||||
from odoo.exceptions import AccessError, ValidationError
|
||||
|
||||
|
||||
class ArticleMember(models.Model):
|
||||
_name = 'knowledge.article.member'
|
||||
_description = 'Article Member'
|
||||
_rec_name = 'partner_id'
|
||||
|
||||
article_id = fields.Many2one(
|
||||
'knowledge.article', 'Article',
|
||||
ondelete='cascade', required=True)
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner', 'Partner',
|
||||
index=True, ondelete='cascade', required=True)
|
||||
permission = fields.Selection(
|
||||
[('write', 'Can edit'),
|
||||
('read', 'Can read'),
|
||||
('none', 'No access')],
|
||||
required=True, default='read')
|
||||
article_permission = fields.Selection(
|
||||
related='article_id.inherited_permission',
|
||||
readonly=True, store=True)
|
||||
|
||||
_sql_constraints = [
|
||||
('unique_article_partner',
|
||||
'unique(article_id, partner_id)',
|
||||
'You already added this partner on this article.')
|
||||
]
|
||||
|
||||
@api.constrains('article_permission', 'permission')
|
||||
def _check_is_writable(self, on_unlink=False):
|
||||
""" Articles must always have at least one writer. This constraint is done
|
||||
on member level, in coordination to the constraint on article model (see
|
||||
``_check_is_writable`` on ``knowledge.article``).
|
||||
|
||||
Since this constraint only triggers if we have at least one member another
|
||||
validation is done on article model. The article_permission related field
|
||||
has been added and stored to force triggering this constraint when
|
||||
article.permission is modified.
|
||||
|
||||
Ǹote: computation is done in Py instead of using optimized SQL queries
|
||||
because value are not yet in DB at this point.
|
||||
|
||||
:param bool on_unlink: when called on unlink we must remove the members
|
||||
in self (the ones that will be deleted) to check if one of the remaining
|
||||
members has write access.
|
||||
"""
|
||||
if self.env.context.get('knowledge_member_skip_writable_check'):
|
||||
return
|
||||
|
||||
articles_to_check = self.article_id.filtered(lambda a: a.inherited_permission != 'write')
|
||||
if not articles_to_check:
|
||||
return
|
||||
|
||||
if on_unlink:
|
||||
deleted_members_by_article = dict.fromkeys(articles_to_check.ids, self.env['knowledge.article.member'])
|
||||
for member in self.filtered(lambda member: member.article_id in articles_to_check):
|
||||
deleted_members_by_article[member.article_id.id] |= member
|
||||
|
||||
for article in articles_to_check:
|
||||
# Check on permission on members
|
||||
members_to_check = article.article_member_ids
|
||||
if on_unlink:
|
||||
members_to_check -= deleted_members_by_article[article.id]
|
||||
if any(m.permission == 'write' for m in members_to_check):
|
||||
continue
|
||||
|
||||
members_to_exclude = deleted_members_by_article[article.id] if on_unlink else False
|
||||
if not article._has_write_member(members_to_exclude=members_to_exclude):
|
||||
raise ValidationError(
|
||||
_("Article '%s' should always have a writer: inherit write permission, or have a member with write access",
|
||||
article.display_name)
|
||||
)
|
||||
|
||||
def write(self, vals):
|
||||
""" Whatever rights, avoid any attempt at privilege escalation. """
|
||||
if ('article_id' in vals or 'partner_id' in vals) and not self.env.is_admin():
|
||||
raise AccessError(_("Can not update the article or partner of a member."))
|
||||
return super().write(vals)
|
||||
|
||||
@api.ondelete(at_uninstall=False)
|
||||
def _unlink_except_no_writer(self):
|
||||
""" When removing a member, the constraint is not triggered.
|
||||
We need to check manually on article with no write permission that we do not remove the last write member """
|
||||
self._check_is_writable(on_unlink=True)
|
||||
|
||||
def _get_invitation_hash(self):
|
||||
""" We use a method instead of a field in order to reduce DB space."""
|
||||
self.ensure_one()
|
||||
return tools.hmac(self.env(su=True),
|
||||
'knowledge-article-invite',
|
||||
f'{self.id}-{self.create_date}-{self.partner_id.id}-{self.article_id.id}'
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class KnowledgeStage(models.Model):
|
||||
_name = "knowledge.article.stage"
|
||||
_description = "Knowledge Stage"
|
||||
_order = 'parent_id, sequence, id'
|
||||
|
||||
name = fields.Char(required=True, translate=True)
|
||||
sequence = fields.Integer(default=1)
|
||||
fold = fields.Boolean("Folded in kanban view")
|
||||
parent_id = fields.Many2one("knowledge.article", string="Owner Article",
|
||||
required=True, ondelete="cascade", help="Stages are shared among a"
|
||||
"common parent and its children articles."
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ArticleTemplateCategory(models.Model):
|
||||
"""This model represents the categories of the article templates."""
|
||||
_name = "knowledge.article.template.category"
|
||||
_description = "Article Template Category"
|
||||
_order = "sequence ASC, id ASC"
|
||||
|
||||
name = fields.Char(string="Title", translate=True, required=True)
|
||||
sequence = fields.Integer("Category Sequence", default=0, required=True,
|
||||
help="It determines the display order of the category")
|
||||
@@ -0,0 +1,123 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class KnowledgeArticleThread(models.Model):
|
||||
"""
|
||||
This is the model for a comment thread linked to a `knowledge.article`. Each thread inherits
|
||||
the `mail.thread` mixin.
|
||||
|
||||
These threads allow end-users to discuss specific parts of the body of a knowledge article.
|
||||
Which enables reviews, taking notes, pinging a colleague to get more information on a topic, ...
|
||||
|
||||
Each initial comment starts its own thread, which will then accumulate replies, reactions, etc.
|
||||
It is also possible to mark a thread as closed so that it no longer appears inside the editor
|
||||
of the article if the conversation does not need to be continued.
|
||||
"""
|
||||
_name = "knowledge.article.thread"
|
||||
_description = "Article Discussion Thread"
|
||||
_inherit = ['mail.thread']
|
||||
_mail_post_access = 'read' # if you can read, you can post a message on an article thread
|
||||
_order = 'write_date desc, id desc'
|
||||
_rec_name = 'display_name'
|
||||
|
||||
article_id = fields.Many2one('knowledge.article', ondelete="cascade", readonly=True, required=True)
|
||||
is_resolved = fields.Boolean("Thread Closed", tracking=True)
|
||||
|
||||
@api.depends('article_id')
|
||||
def _compute_display_name(self):
|
||||
for record in self:
|
||||
record.display_name = record.article_id.display_name
|
||||
|
||||
def toggle_thread(self):
|
||||
"""Toggles the resolution state of the article"""
|
||||
self.ensure_one()
|
||||
self.is_resolved = not self.is_resolved
|
||||
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# CRUD METHODS
|
||||
# ===========================================================================
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
return super(KnowledgeArticleThread, self.with_context(mail_create_nolog=True)).create(vals_list)
|
||||
|
||||
# ==========================================================================
|
||||
# THREAD OVERRIDES
|
||||
# ==========================================================================
|
||||
|
||||
def message_post(self, **kwargs):
|
||||
"""This function overrides the 'mail.thread' message_post in order to let portal users that
|
||||
have access to an article to post a message in the thread.
|
||||
We need to apply this method with sudo for portal users because they do not have access to the
|
||||
`mail.message` model, which is needed to post the message.
|
||||
This idea is based on the method `portal_chatter_post` which needs to check access rights in
|
||||
order to let the portal post in the chatter.
|
||||
|
||||
Before posting as a portal we filter what's being sent to lessen security risks. Notably
|
||||
partner_ids should be a list of ids (not the records themselves) so that we don't allow command
|
||||
executions, even with the sudo call.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.env.user._is_portal() and self.article_id.user_has_access:
|
||||
authorized_keys = {'body', 'partner_ids', 'author_id'}
|
||||
return super(KnowledgeArticleThread, self.sudo()).message_post(
|
||||
**{key: kwargs.get(key) for key in authorized_keys},
|
||||
message_type='comment', subtype_xmlid='mail.mt_comment'
|
||||
)
|
||||
return super().message_post(**kwargs)
|
||||
|
||||
def _get_access_action(self, access_uid=None, force_website=False):
|
||||
self.ensure_one()
|
||||
user = self.env['res.users'].sudo().browse(access_uid) if access_uid else self.env.user
|
||||
action = {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': f'/knowledge/article/{self.article_id.id}',
|
||||
}
|
||||
if access_uid is None:
|
||||
action['target_type'] = 'public'
|
||||
if self.article_id.with_user(user).user_has_access or access_uid is None:
|
||||
return action
|
||||
return super()._get_access_action(access_uid=access_uid, force_website=force_website)
|
||||
|
||||
def _notify_thread_by_email(self, message, recipients_data, **kwargs):
|
||||
"""We need to override this method to set our own mail template to be sent to users that
|
||||
have been tagged inside a comment. We are using the template 'knowledge.knowledge_mail_notification_layout'
|
||||
which is a simple template comprised of the comment sent and the person that tagged the notified user.
|
||||
"""
|
||||
|
||||
kwargs['msg_vals'] = {**kwargs['msg_vals'], 'email_layout_xmlid': 'knowledge.knowledge_mail_notification_layout'}
|
||||
|
||||
return super()._notify_thread_by_email(message, recipients_data, **kwargs)
|
||||
|
||||
def _message_compute_subject(self):
|
||||
self.ensure_one()
|
||||
return _('New Mention in %s') % self.display_name
|
||||
|
||||
def _notify_get_recipients_groups(self, message, model_description, msg_vals=None):
|
||||
groups = super()._notify_get_recipients_groups(
|
||||
message, model_description, msg_vals=msg_vals
|
||||
)
|
||||
if message.model != 'knowledge.article.thread':
|
||||
return groups
|
||||
|
||||
self.ensure_one()
|
||||
action = self._notify_get_action_link('controller', controller='/knowledge/thread/resolve', **msg_vals)
|
||||
user_actions = [{'url': action, 'title': _('Mark Comment as Closed')}]
|
||||
|
||||
new_groups = [(
|
||||
'group_knowledge_article_thread_portal_and_users',
|
||||
lambda pdata:
|
||||
pdata['uid'] and self.article_id.with_user(pdata['uid']).user_has_access,
|
||||
{
|
||||
'actions': user_actions,
|
||||
'active': True,
|
||||
'has_button_access': True,
|
||||
}
|
||||
)]
|
||||
|
||||
return new_groups + groups
|
||||
@@ -0,0 +1,43 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class Cover(models.Model):
|
||||
_name = "knowledge.cover"
|
||||
_description = "Knowledge Cover"
|
||||
|
||||
attachment_id = fields.Many2one("ir.attachment", string="Cover attachment", required=True, ondelete="cascade")
|
||||
article_ids = fields.One2many("knowledge.article", "cover_image_id", string="Articles using cover")
|
||||
attachment_url = fields.Char("Cover URL", compute="_compute_attachment_url", store=True)
|
||||
|
||||
@api.depends('attachment_id')
|
||||
def _compute_attachment_url(self):
|
||||
# Add an url for frontend access.
|
||||
for cover in self:
|
||||
if cover.attachment_id.url:
|
||||
cover.attachment_url = cover.attachment_id.url
|
||||
else:
|
||||
access_token = cover.attachment_id.generate_access_token()[0]
|
||||
cover.attachment_url = "/web/image/%s?access_token=%s" % (cover.attachment_id.id, access_token)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
""" Create the covers, then link the attachments used to the created
|
||||
records, because when uploading a new cover, the attachment is uploaded
|
||||
with res_id=0, then the cover is created using the uploaded attachment.
|
||||
"""
|
||||
if any(len(vals) == 1 and 'name' in vals for vals in vals_list):
|
||||
raise UserError(_('You cannot create a new Knowledge Cover from here.'))
|
||||
covers = super().create(vals_list)
|
||||
|
||||
for cover in covers.filtered(lambda cover: not cover.attachment_id.res_id):
|
||||
cover.attachment_id.write({'res_model': 'knowledge.cover', 'res_id': cover.id, })
|
||||
|
||||
return covers
|
||||
|
||||
@api.autovacuum
|
||||
def _gc_unused_covers(self):
|
||||
return self.with_context(active_test=False).search([('article_ids', '=', False)]).unlink()
|
||||
@@ -0,0 +1,16 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models
|
||||
|
||||
|
||||
class Partner(models.Model):
|
||||
_name = 'res.partner'
|
||||
_inherit = 'res.partner'
|
||||
|
||||
def unlink(self):
|
||||
""" This override will delete all the private articles linked to the deleted partners. """
|
||||
self.env['knowledge.article.member'].sudo().search(
|
||||
[('partner_id', 'in', self.ids), ('article_id.category', '=', 'private')]
|
||||
).article_id.unlink()
|
||||
return super(Partner, self).unlink()
|
||||
@@ -0,0 +1,50 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models, api
|
||||
from odoo.tools.translate import _lt
|
||||
|
||||
|
||||
class Users(models.Model):
|
||||
_name = 'res.users'
|
||||
_inherit = 'res.users'
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
users = super(Users, self).create(vals_list)
|
||||
if not self.env.context.get('knowledge_skip_onboarding_article'):
|
||||
users.filtered(lambda user: not user.partner_share)._generate_tutorial_articles()
|
||||
return users
|
||||
|
||||
def _generate_tutorial_articles(self):
|
||||
articles_to_create = []
|
||||
for user in self:
|
||||
self = self.with_context(lang=user.lang or self.env.user.lang)
|
||||
render_ctx = {'object': user}
|
||||
body = self.env['ir.qweb']._render(
|
||||
'knowledge.knowledge_article_user_onboarding',
|
||||
render_ctx,
|
||||
minimal_qcontext=True,
|
||||
raise_if_not_found=False
|
||||
)
|
||||
if not body:
|
||||
break
|
||||
|
||||
welcome = _lt('Welcome %s', user.name)
|
||||
articles_to_create.append({
|
||||
'article_member_ids': [(0, 0, {
|
||||
'partner_id': user.partner_id.id,
|
||||
'permission': 'write',
|
||||
})],
|
||||
'body': body,
|
||||
'icon': "👋",
|
||||
'internal_permission': 'none',
|
||||
'is_article_visible_by_everyone': False,
|
||||
'favorite_ids': [(0, 0, {
|
||||
'sequence': 0,
|
||||
'user_id': user.id,
|
||||
})],
|
||||
'name': welcome,
|
||||
})
|
||||
|
||||
if articles_to_create:
|
||||
self.env['knowledge.article'].sudo().create(articles_to_create)
|
||||
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import knowledge_article
|
||||
from . import tools
|
||||
@@ -0,0 +1,265 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models
|
||||
from odoo.tools import populate
|
||||
from odoo.addons.base.models.ir_qweb_fields import nl2br
|
||||
from odoo.addons.knowledge.populate import tools
|
||||
|
||||
|
||||
class KnowledgeArticle(models.Model):
|
||||
_inherit = 'knowledge.article'
|
||||
_populate_dependencies = [
|
||||
'res.users', # membership management
|
||||
]
|
||||
# be careful that size only defines the amount of ROOT articles
|
||||
# there will be a lot more articles in the end, counting children
|
||||
# see underlying docstring in '_prepare_children_articles'
|
||||
# manual testing gives around 3000-4000 generated articles for "medium" size
|
||||
# (giving a bigger size also increases the amount of res.users we have for members config)
|
||||
# /!\ using "large" can take a very long time as we can't take advantage of the batching
|
||||
# because of parent / children relationship, everything is committed in one singular batch
|
||||
_populate_sizes = {
|
||||
'small': 5, # will result in ~1.500
|
||||
'medium': 20, # will result in ~5.000 records
|
||||
'large': 100, # will result in ~27.000 records
|
||||
}
|
||||
|
||||
def _populate(self, size):
|
||||
# create some dummy portal users as res.users populate only creates internal users
|
||||
self.env['res.users'].create([{
|
||||
'login': 'portal_user_knowledge_%i' % i,
|
||||
'email': 'portaluserknowledge%i@example.com' % i,
|
||||
'name': 'Portal User %i' % i,
|
||||
'groups_id': [
|
||||
(6, 0, [self.env.ref('base.group_portal').id]),
|
||||
]
|
||||
} for i in range(self.env['res.users']._populate_sizes[size])])
|
||||
|
||||
return super()._populate(size)
|
||||
|
||||
def _populate_factories(self, depth=0):
|
||||
internal_partner_ids = self.env['res.users'].search([
|
||||
('share', '=', False),
|
||||
]).partner_id.ids
|
||||
shared_partner_ids = self.env['res.users'].search([
|
||||
('share', '=', True),
|
||||
]).partner_id.ids
|
||||
|
||||
return self._populate_article_factories(depth, internal_partner_ids, shared_partner_ids)
|
||||
|
||||
def _populate_article_factories(self, depth, internal_partner_ids, shared_partner_ids):
|
||||
random = populate.Random('articles')
|
||||
if depth == 0:
|
||||
names = populate.iterate(tools._a_title_root)
|
||||
elif depth == 1:
|
||||
names = populate.iterate(tools._a_title_top_level)
|
||||
elif depth == 2:
|
||||
names = populate.iterate(tools._a_title_leaf)
|
||||
else:
|
||||
names = populate.randomize(tools._a_title_low_level, seed=random.randint(1, 100))
|
||||
|
||||
return {
|
||||
('name', names),
|
||||
('body', populate.randomize([
|
||||
nl2br('%s' % tuple(tools.a_body_content_lorem_ipsum[:1])),
|
||||
nl2br('%s %s' % tuple(tools.a_body_content_lorem_ipsum[:2])),
|
||||
nl2br('%s %s %s' % tuple(tools.a_body_content_lorem_ipsum[:3])),
|
||||
])),
|
||||
('child_ids', populate.compute(lambda *args, **kwargs: self._prepare_children_articles(depth, internal_partner_ids, shared_partner_ids))),
|
||||
('icon', populate.randomize(['🗒️', '🤖', '⭐', '🚀', '🎉', '☕', '🏆', '🛫', '💰', '📫'])),
|
||||
('internal_permission', populate.compute(lambda *args, **kwargs: self._generate_internal_permission(depth))),
|
||||
('is_locked', populate.randomize([True, False], [0.02, 0.98], seed=random.randint(1, 100))),
|
||||
('full_width', populate.randomize([True, False], [0.2, 0.8], seed=random.randint(1, 100))),
|
||||
('article_member_ids', populate.compute(lambda random, values, **kwargs: self._prepare_member_ids(depth, values, internal_partner_ids, shared_partner_ids))),
|
||||
('is_locked', populate.randomize([True, False], [0.02, 0.98])),
|
||||
('full_width', populate.randomize([True, False], [0.2, 0.8])),
|
||||
# TODO add article items / fill some properties fields
|
||||
('favorite_ids', populate.compute(lambda values, *args, **kwargs: self._prepare_favorites(values, internal_partner_ids, shared_partner_ids))),
|
||||
}
|
||||
|
||||
def _prepare_favorites(self, values, internal_partner_ids, shared_partner_ids):
|
||||
"""In Knowledge we offer the possibility for a user to set articles as favorites.
|
||||
To handle this in the model we have a table `knowledge.article.favorite` that stores every
|
||||
favorited article for each user.
|
||||
|
||||
Every article has a chance to be the favorite of a user but it is more likely that user will add
|
||||
a root as a favorite than a child.
|
||||
Thus to prepare the favorites we will need some information:
|
||||
* Will the article be favorited by a user ?
|
||||
* Which user wants to add the article to its favorite ?
|
||||
|
||||
Of course we do not want a lot of articles being favorited so the initial check will reject many of the attempts,
|
||||
but if we want to add some favorite_ids to an article then we will check how many users will add the article as
|
||||
favorite.
|
||||
This is how we will populate the table."""
|
||||
random = populate.Random('favorites')
|
||||
|
||||
article_member_ids = values.get('article_member_ids', False)
|
||||
internal_permission = values.get('internal_permission', False)
|
||||
# 2% of the articles can be set as favorites
|
||||
can_be_favorite = random.choices([True, False], weights=[0.02, 0.98], k=1)[0] and (
|
||||
internal_permission or article_member_ids)
|
||||
if not can_be_favorite:
|
||||
return []
|
||||
|
||||
admin_partner_id = self.env.ref('base.user_admin').partner_id.id
|
||||
|
||||
# sampling some partners
|
||||
internals = random.sample(internal_partner_ids, k=min(random.randint(2, 10), len(internal_partner_ids)))
|
||||
externals = random.sample(shared_partner_ids, k=min(random.randint(2, 10), len(shared_partner_ids)))
|
||||
# 50% chance to force admin into the mix (easier for testing)
|
||||
if admin_partner_id not in internals and random.choices([True, False], weights=[0.5, 0.5], k=1)[0]:
|
||||
internals.append(admin_partner_id)
|
||||
|
||||
# If there are article members, check their permissions, otherwise, all are favorites
|
||||
all_partners = internals + externals
|
||||
favorite_partner_ids, partners_to_check_access = ([], all_partners) if article_member_ids else (all_partners, [])
|
||||
|
||||
for partner_id in partners_to_check_access:
|
||||
member_access = next(
|
||||
(
|
||||
member[2]['permission']
|
||||
for member in article_member_ids
|
||||
if member[2]['partner_id'] == partner_id
|
||||
),
|
||||
False
|
||||
)
|
||||
|
||||
if member_access == 'none':
|
||||
continue # specified no access for this partner -> pass
|
||||
|
||||
if internal_permission == 'none' and not member_access:
|
||||
continue # internal permission denies access -> pass
|
||||
|
||||
favorite_partner_ids.append(partner_id)
|
||||
|
||||
# sadly the knowledge.article.favorite model needs user_id, so we have to fetch them
|
||||
linked_users = self.env['res.users'].search([('partner_id', 'in', favorite_partner_ids)])
|
||||
user_per_partner = {
|
||||
user.partner_id.id: user
|
||||
for user in linked_users
|
||||
}
|
||||
|
||||
return [(0, 0, {
|
||||
'user_id': user_per_partner[partner_id].id
|
||||
}) for partner_id in favorite_partner_ids]
|
||||
|
||||
def _prepare_children_articles(self, depth, internal_partner_ids, shared_partner_ids):
|
||||
""" As knowledge.article is a bit meaningless without a parent / children configuration,
|
||||
this methods aims to fill-up child_ids recursively.
|
||||
|
||||
As the regular populate only allows to specify the 'total amount of records' we want to
|
||||
create, and it does not handle parent / children relationship, we apply a specific logic for
|
||||
children articles.
|
||||
|
||||
The idea is to always have children for root articles and then lower the chances of generating
|
||||
children as you go into higher 'depth', with a maximum of 5 levels in total.
|
||||
The amount of children is chosen randomly between 2 and 10.
|
||||
|
||||
The code that generates values re-uses the '_populate_factories' method and increases the depth
|
||||
every time we loop. """
|
||||
random = populate.Random('childrenarticles')
|
||||
|
||||
if depth > 4 or random.randint(1, depth + 1) != depth + 1:
|
||||
# higher depth means lower chance of having children articles
|
||||
# depth of 0 -> 100% (randint(1,1) needs to equal 1)
|
||||
# depth of 1 -> 50% (randint(1,2) needs to equal 2)
|
||||
# depth of 2 -> 33% (randint(1,2,3) needs to equal 3)
|
||||
# ...
|
||||
return []
|
||||
|
||||
record_count = 0
|
||||
create_values = []
|
||||
field_generators = self._populate_article_factories(depth + 1, internal_partner_ids, shared_partner_ids)
|
||||
|
||||
generator = populate.chain_factories(field_generators, self._name)
|
||||
for _i in range(random.randint(2, 10)):
|
||||
values = next(generator)
|
||||
values.pop('__complete')
|
||||
create_values.append((0, 0, values))
|
||||
record_count += 1
|
||||
|
||||
return create_values
|
||||
|
||||
def _generate_internal_permission(self, depth):
|
||||
random = populate.Random('internalpermission')
|
||||
if depth != 0:
|
||||
# we keep it simple and only set custom internal permission to root articles
|
||||
return False
|
||||
|
||||
category = random.choices(['workspace', 'private'], weights=[0.9, 0.1], k=1)[0]
|
||||
if category == 'private':
|
||||
# private articles should be 'none' and handled at members level
|
||||
return 'none'
|
||||
|
||||
# 80% will be write, 20% read
|
||||
return random.choices(['write', 'read'], weights=[0.8, 0.2], k=1)[0]
|
||||
|
||||
def _prepare_member_ids(self, depth, values, internal_partner_ids, shared_partner_ids):
|
||||
random = populate.Random('members')
|
||||
private_member_values = []
|
||||
|
||||
admin_partner_id = self.env.ref('base.user_admin').partner_id.id
|
||||
if values.get('internal_permission') == 'none':
|
||||
# private article -> keep it simple and assume that people will test with admin user
|
||||
# there will be one auto-generated private article for every user anyway (so size is already big)
|
||||
private_member_values = [(0, 0, {
|
||||
'partner_id': admin_partner_id,
|
||||
'permission': 'write'
|
||||
})]
|
||||
|
||||
# we tweak the end range, so that the deeper you go, the less chance you have of having
|
||||
# members configuration
|
||||
randint = random.randint(1, (depth + 1) * 10)
|
||||
if randint > 4 and values.get('internal_permission') == 'write':
|
||||
# 60% base chance of not having member configuration
|
||||
# unless we don't have a 'write' internal permission, in which case we need members
|
||||
return private_member_values
|
||||
|
||||
internal_partner_ids = random.sample(internal_partner_ids, k=min(random.randint(2, 10), len(internal_partner_ids)))
|
||||
members_values = []
|
||||
for partner_id in internal_partner_ids:
|
||||
if partner_id == admin_partner_id and private_member_values:
|
||||
continue # avoid duplicate member
|
||||
|
||||
# force one write members, otherwise 70% of write, 25% read, 5% none
|
||||
permission = 'write' if partner_id == internal_partner_ids[0] else random.choices(
|
||||
['write', 'read', 'none'], weights=[70, 25, 5], k=1
|
||||
)[0]
|
||||
|
||||
members_values.append({
|
||||
'partner_id': partner_id,
|
||||
'permission': permission
|
||||
})
|
||||
|
||||
internal_member_ids = [
|
||||
(0, 0, member_values)
|
||||
for member_values in members_values
|
||||
]
|
||||
|
||||
if randint > 2 or values.get('internal_permission') == 'read':
|
||||
# 20% base chance of having only internal members
|
||||
return private_member_values + internal_member_ids
|
||||
|
||||
# 10% base chance of having only external members
|
||||
shared_partner_ids = random.sample(shared_partner_ids, k=min(random.randint(2, 10), len(shared_partner_ids)))
|
||||
members_values = []
|
||||
for partner_id in shared_partner_ids:
|
||||
# 90% of write
|
||||
members_values.append({
|
||||
'partner_id': partner_id,
|
||||
'permission': random.choices(['read', 'none'], weights=[0.9, 0.1], k=1)[0]
|
||||
})
|
||||
|
||||
shared_member_ids = [
|
||||
(0, 0, member_values)
|
||||
for member_values in members_values
|
||||
]
|
||||
|
||||
if randint == 2 and values.get('internal_permission') == 'write':
|
||||
# 10% base chance of having only external members (only for write internal permission)
|
||||
return shared_member_ids
|
||||
|
||||
# 10% chance to have a mix of both
|
||||
return private_member_values + internal_member_ids + shared_member_ids
|
||||
@@ -0,0 +1,54 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
# article
|
||||
_a_title_root = [
|
||||
'HR', 'Accounting', 'Company', 'Day 2 Day',
|
||||
'Branding', 'Business', 'Marketing'
|
||||
]
|
||||
|
||||
_a_title_top_level = [
|
||||
'FAQ', 'Meetings', 'TODOs', 'Documentation',
|
||||
'Ideas', '(To Sort / Remove)'
|
||||
]
|
||||
|
||||
_a_title_leaf = [
|
||||
'Global explanation', 'In Details', 'I have more Questions?',
|
||||
'Going further', 'Closing Words'
|
||||
]
|
||||
|
||||
_a_title_low_level = [
|
||||
'Friday Meeting Notes', 'Contacts Information', '[TO CLEAN]',
|
||||
'Untitled', 'Work In Progress'
|
||||
]
|
||||
|
||||
# 3 paragraphs to combine into an article body
|
||||
a_body_content_lorem_ipsum = ["""
|
||||
<p>
|
||||
<h1>Lorem ipsum.</h1>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
|
||||
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
|
||||
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
|
||||
Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
|
||||
</p>
|
||||
""",
|
||||
"""
|
||||
<p>
|
||||
<h1>Perspiciatis unde omnis.</h1>
|
||||
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.
|
||||
Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.
|
||||
Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.
|
||||
Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur?
|
||||
Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
|
||||
</p>
|
||||
""",
|
||||
"""
|
||||
<p>
|
||||
<h1>Vero eos et accusamus</h1>
|
||||
At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga.
|
||||
Et harum quidem rerum facilis est et expedita distinctio.
|
||||
Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus.
|
||||
Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae.
|
||||
Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat.
|
||||
</p>
|
||||
"""]
|
||||
@@ -0,0 +1,30 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_knowledge_article_all,access.knowledge.article.all,knowledge.model_knowledge_article,,0,0,0,0
|
||||
access_knowledge_article_portal,access.knowledge.article.portal,knowledge.model_knowledge_article,base.group_portal,1,1,1,0
|
||||
access_knowledge_article_user,access.knowledge.article.user,knowledge.model_knowledge_article,base.group_user,1,1,1,0
|
||||
access_knowledge_article_system,access.knowledge.article.system,knowledge.model_knowledge_article,base.group_system,1,1,1,1
|
||||
access_knowledge_article_thread_all,access.knowledge.article.thread.all,knowledge.model_knowledge_article_thread,,0,0,0,0
|
||||
access_knowledge_article_thread_portal,access.knowledge.article.thread.portal,knowledge.model_knowledge_article_thread,base.group_portal,1,1,1,0
|
||||
access_knowledge_article_thread_user,access.knowledge.article.thread.user,knowledge.model_knowledge_article_thread,base.group_user,1,1,1,0
|
||||
access_knowledge_article_thread_system,access.knowledge.article.thread.system,knowledge.model_knowledge_article_thread,base.group_system,1,1,1,1
|
||||
access_knowledge_article_member_all,access.knowledge.article.member.all,knowledge.model_knowledge_article_member,,0,0,0,0
|
||||
access_knowledge_article_member_portal,access.knowledge.article.member.portal,knowledge.model_knowledge_article_member,base.group_portal,1,0,0,0
|
||||
access_knowledge_article_member_user,access.knowledge.article.member.user,knowledge.model_knowledge_article_member,base.group_user,1,0,0,0
|
||||
access_knowledge_article_member_system,access.knowledge.article.member.system,knowledge.model_knowledge_article_member,base.group_system,1,1,1,1
|
||||
access_knowledge_article_favorite_all,access.knowledge.article.favorite.all,knowledge.model_knowledge_article_favorite,,0,0,0,0
|
||||
access_knowledge_article_favorite_portal,access.knowledge.article.favorite.portal,knowledge.model_knowledge_article_favorite,base.group_portal,1,1,1,1
|
||||
access_knowledge_article_favorite_user,access.knowledge.article.favorite.user,knowledge.model_knowledge_article_favorite,base.group_user,1,1,1,1
|
||||
access_knowledge_article_favorite_system,access.knowledge.article.favorite.system,knowledge.model_knowledge_article_favorite,base.group_system,1,1,1,1
|
||||
access_knowledge_article_stage_all,access.knowledge.article.stage.all,knowledge.model_knowledge_article_stage,,0,0,0,0
|
||||
access_knowledge_article_stage_portal,access.knowledge.article.stage.portal,knowledge.model_knowledge_article_stage,base.group_portal,1,1,1,1
|
||||
access_knowledge_article_stage_user,access.knowledge.article.stage.user,knowledge.model_knowledge_article_stage,base.group_user,1,1,1,1
|
||||
access_knowledge_article_stage_system,access.knowledge.article.stage.system,knowledge.model_knowledge_article_stage,base.group_system,1,1,1,1
|
||||
access_knowledge_article_template_category_all,access.knowledge.article.template.category.all,knowledge.model_knowledge_article_template_category,,0,0,0,0
|
||||
access_knowledge_article_template_category_system,access.knowledge.article.template.category.system,knowledge.model_knowledge_article_template_category,base.group_system,1,1,1,1
|
||||
access_knowledge_article_template_category_user,access.knowledge.article.template.category.user,knowledge.model_knowledge_article_template_category,base.group_user,1,0,0,0
|
||||
access_knowledge_cover_all,access.knowledge.cover.all,knowledge.model_knowledge_cover,,0,0,0,0
|
||||
access_knowledge_cover_user,access.knowledge.cover.user,knowledge.model_knowledge_cover,base.group_user,1,1,1,1
|
||||
access_knowledge_cover_system,access.knowledge.cover.system,knowledge.model_knowledge_cover,base.group_system,1,1,1,1
|
||||
access_knowledge_invite_all,access.knowledge.invite.all,knowledge.model_knowledge_invite,,0,0,0,0
|
||||
access_knowledge_invite_user,access.knowledge.invite.user,knowledge.model_knowledge_invite,base.group_user,1,1,1,0
|
||||
access_knowledge_invite_system,access.knowledge.invite.system,knowledge.model_knowledge_invite,base.group_system,1,1,1,1
|
||||
|
@@ -0,0 +1,121 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
<!-- ARTICLE -->
|
||||
<record id="rule_knowledge_article_system" model="ir.rule">
|
||||
<field name="name">Articles: System = CRUD on all articles</field>
|
||||
<field name="model_id" ref="model_knowledge_article"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_system'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_knowledge_article_users_read" model="ir.rule">
|
||||
<field name="name">Articles: users/portal: read based on access</field>
|
||||
<field name="model_id" ref="model_knowledge_article"/>
|
||||
<field name="domain_force">[('user_has_access', '=', True)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_user')), (4, ref('base.group_portal'))]"/>
|
||||
<field name="perm_write" eval="False"/>
|
||||
<field name="perm_create" eval="False"/>
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_knowledge_article_users_write" model="ir.rule">
|
||||
<field name="name">Articles: users/portal: write based on flag</field>
|
||||
<field name="model_id" ref="model_knowledge_article"/>
|
||||
<field name="domain_force">[('user_has_write_access', '=', True)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_user')), (4, ref('base.group_portal'))]"/>
|
||||
<field name="perm_read" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- ARTICLE MEMBER -->
|
||||
<record id="rule_knowledge_article_member_users" model="ir.rule">
|
||||
<field name="name">Article members: users/portal: read article members</field>
|
||||
<field name="model_id" ref="model_knowledge_article_member"/>
|
||||
<field name="domain_force">[('article_id.user_has_access', '=', True)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_user')), (4, ref('base.group_portal'))]"/>
|
||||
<field name="perm_write" eval="False"/>
|
||||
<field name="perm_create" eval="False"/>
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_knowledge_article_member_system" model="ir.rule">
|
||||
<field name="name">Article members: System CRUD all</field>
|
||||
<field name="model_id" ref="model_knowledge_article_member"/>
|
||||
<field name="domain_force">[(1,'=',1)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_system'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- ARTICLE FAVORITE -->
|
||||
<record id="rule_knowledge_article_favorite_users" model="ir.rule">
|
||||
<field name="name">Article favorite: users/portal: own + readable articles</field>
|
||||
<field name="model_id" ref="model_knowledge_article_favorite"/>
|
||||
<field name="domain_force">[('user_id', '=', user.id), ('article_id.user_has_access', '=', True)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_portal')), (4, ref('base.group_user'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_knowledge_article_favorite_system" model="ir.rule">
|
||||
<field name="name">Article favorite: System CRUD all</field>
|
||||
<field name="model_id" ref="model_knowledge_article_favorite"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_system'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- ITEM STAGES -->
|
||||
<record id="rule_knowledge_article_stage_users_read" model="ir.rule">
|
||||
<field name="name">Item Stages (Read): users/portal: readable articles</field>
|
||||
<field name="model_id" ref="model_knowledge_article_stage"/>
|
||||
<field name="domain_force">[('parent_id.user_has_access', '=', True)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_portal')), (4, ref('base.group_user'))]"/>
|
||||
<field name="perm_write" eval="False"/>
|
||||
<field name="perm_create" eval="False"/>
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_knowledge_article_stage_users_write" model="ir.rule">
|
||||
<field name="name">Item Stages (Create/Write/Unlink): users/portal: writable articles</field>
|
||||
<field name="model_id" ref="model_knowledge_article_stage"/>
|
||||
<field name="domain_force">[('parent_id.user_has_write_access', '=', True)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_user')), (4, ref('base.group_portal'))]"/>
|
||||
<field name="perm_read" eval="False"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_knowledge_article_stage_system" model="ir.rule">
|
||||
<field name="name">Item Stages: System CRUD all</field>
|
||||
<field name="model_id" ref="model_knowledge_article_stage"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_system'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- INVITE WIZARD -->
|
||||
<record id="rule_knowledge_invite_users" model="ir.rule">
|
||||
<field name="name">Invite: Users invite members</field>
|
||||
<field name="model_id" ref="model_knowledge_invite"/>
|
||||
<field name="domain_force">[('article_id.user_has_write_access', '=', True)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_knowledge_invite_system" model="ir.rule">
|
||||
<field name="name">Invite: System invite members</field>
|
||||
<field name="model_id" ref="model_knowledge_invite"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_system'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- ARTICLE THREADS -->
|
||||
<record id="rule_knowledge_article_thread_read" model="ir.rule">
|
||||
<field name="name">Articles Threads: portal/users: read based on article access</field>
|
||||
<field name="model_id" ref="model_knowledge_article_thread"/>
|
||||
<field name="domain_force">[('article_id.user_has_access', '=', True)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_user')), (4, ref('base.group_portal'))]"/>
|
||||
<field name="perm_write" eval="False"/>
|
||||
<field name="perm_create" eval="False"/>
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_knowledge_article_thread_write" model="ir.rule">
|
||||
<field name="name">Article Threads: portal/users: write and create based on article write access</field>
|
||||
<field name="model_id" ref="model_knowledge_article_thread"/>
|
||||
<field name="domain_force">[('article_id.user_has_write_access', '=', True)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_user')), (4, ref('base.group_portal'))]"/>
|
||||
<field name="perm_read" eval="False"/>
|
||||
</record>
|
||||
</odoo>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg width="50" height="50" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"><path d="M17 3.99C17 2 19 0 21 0h21v35a1 1 0 0 1-1.572.82L32 30c-1.5-1-3.5-1-5 0l-8.428 5.82A1 1 0 0 1 17 35V3.99Z" fill="#1AD3BB"/><path d="M8 17.99C8 16 10 14 12 14h21v35a1 1 0 0 1-1.572.82L23 44c-1.5-1-3.5-1-5 0l-8.428 5.82A1 1 0 0 1 8 49V17.99Z" fill="#985184"/><path d="M33 30.658 32 30c-1.5-1-3.5-1-5 0l-8.428 5.82A1 1 0 0 1 17 35V14h16v16.658Z" fill="#005E7A"/></svg>
|
||||
|
After Width: | Height: | Size: 458 B |
+118
@@ -0,0 +1,118 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { Component, onWillStart, useEffect, useRef, useState } from "@odoo/owl";
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
import { groupBy } from "@web/core/utils/arrays";
|
||||
import { HtmlField } from "@web_editor/js/backend/html_field";
|
||||
import { Record } from "@web/model/record";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
|
||||
/**
|
||||
* This component will display an article template picker. The user will be able
|
||||
* to preview the article templates and select the one they want.
|
||||
*/
|
||||
export class ArticleTemplatePickerDialog extends Component {
|
||||
static template = "knowledge.ArticleTemplatePickerDialog";
|
||||
static components = {
|
||||
Dialog,
|
||||
Record,
|
||||
HtmlField
|
||||
};
|
||||
static props = {
|
||||
onLoadTemplate: { type: Function },
|
||||
close: { type: Function },
|
||||
};
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
setup() {
|
||||
super.setup();
|
||||
this.size = "fs";
|
||||
this.title = _t("Select a Template");
|
||||
this.orm = useService("orm");
|
||||
this.scrollView = useRef("scroll-view");
|
||||
this.state = useState({});
|
||||
|
||||
onWillStart(async () => {
|
||||
const templates = await this.orm.searchRead(
|
||||
"knowledge.article",
|
||||
[
|
||||
["is_template", "=", true],
|
||||
["parent_id", "=", false]
|
||||
],
|
||||
[
|
||||
"id",
|
||||
"icon",
|
||||
"template_name",
|
||||
"template_category_id",
|
||||
"template_category_sequence",
|
||||
"template_sequence",
|
||||
],
|
||||
{}
|
||||
);
|
||||
const groups = groupBy(templates, template => template["template_category_id"][0]);
|
||||
this.groups = Object.values(groups).sort((a, b) => {
|
||||
return a[0]["template_category_sequence"] > b[0]["template_category_sequence"];
|
||||
}).map(group => group.sort((a, b) => {
|
||||
return a["template_sequence"] > b["template_sequence"];
|
||||
}));
|
||||
if (this.groups.length > 0) {
|
||||
this.state.resId = this.groups[0][0].id;
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const { el } = this.scrollView;
|
||||
if (el) {
|
||||
el.style.visibility = "visible";
|
||||
}
|
||||
}, () => [this.state.resId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {integer} articleTemplateId
|
||||
*/
|
||||
async onSelectTemplate(articleTemplateId) {
|
||||
const { el } = this.scrollView;
|
||||
el.scrollTop = 0;
|
||||
if (articleTemplateId !== this.state.resId) {
|
||||
el.style.visibility = "hidden";
|
||||
this.state.resId = articleTemplateId;
|
||||
}
|
||||
}
|
||||
|
||||
async onLoadTemplate() {
|
||||
this.props.onLoadTemplate(this.state.resId);
|
||||
this.props.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record} record
|
||||
* @returns {Object}
|
||||
*/
|
||||
getHtmlFieldProps(record) {
|
||||
return {
|
||||
record,
|
||||
readonly: true,
|
||||
name: "template_preview",
|
||||
wysiwygOptions: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Array[String]}
|
||||
*/
|
||||
get articleTemplateFieldNames() {
|
||||
return [
|
||||
"cover_image_url",
|
||||
"icon",
|
||||
"id",
|
||||
"parent_id",
|
||||
"template_name",
|
||||
"template_preview",
|
||||
"template_description",
|
||||
];
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
.o_knowledge_article_template_picker_dialog {
|
||||
.o_knowledge_template_selector {
|
||||
li.active {
|
||||
background: rgba($knowledge-bg--active, 0.2);
|
||||
&:hover {
|
||||
background: rgba($knowledge-bg--active, 0.3);
|
||||
}
|
||||
}
|
||||
li:not(.active) {
|
||||
&:hover {
|
||||
background: rgba($knowledge-bg--hover, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
.o_knowledge_template_preview {
|
||||
mask-image: linear-gradient(black 80%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(black 80%, transparent 100%);
|
||||
.o_article_template_cover {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
object-position: 50% 50%;
|
||||
}
|
||||
.o_article_template_emoji {
|
||||
font-size: 50px;
|
||||
}
|
||||
.o_article_template_cover + .o_article_template_container .o_article_template_emoji {
|
||||
margin-top: -60px;
|
||||
}
|
||||
.o_content {
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(sm) {
|
||||
.o_knowledge_article_template_picker_dialog {
|
||||
height: 750px;
|
||||
& > div {
|
||||
height: 100%;
|
||||
}
|
||||
.o_knowledge_template_selector,
|
||||
.o_knowledge_template_preview {
|
||||
overflow-y: auto;
|
||||
}
|
||||
.o_knowledge_template_selector {
|
||||
width: 25%;
|
||||
border-right: 1px solid $o-gray-300;
|
||||
}
|
||||
}
|
||||
}
|
||||
@include media-breakpoint-down(sm) {
|
||||
.o_knowledge_article_template_picker_dialog {
|
||||
.o_knowledge_template_selector {
|
||||
border-bottom: 1px solid $o-gray-300;
|
||||
}
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="knowledge.ArticleTemplatePickerDialog">
|
||||
<Dialog size="size" title="title" bodyClass="'o_knowledge_article_template_picker_dialog p-0'">
|
||||
<div t-if="groups.length > 0" class="d-sm-flex">
|
||||
<div class="o_knowledge_template_selector flex-shrink-0 p-3">
|
||||
<t t-foreach="groups" t-as="group" t-key="group_index">
|
||||
<div class="text-truncate text-muted mb-2">
|
||||
<t t-out="group[0]['template_category_id'][1]"/>
|
||||
</div>
|
||||
<ul class="list-unstyled">
|
||||
<li t-foreach="group" t-as="template" t-key="template.id"
|
||||
t-on-click="event => this.onSelectTemplate(template.id)"
|
||||
t-attf-class="text-nowrap cursor-pointer #{ template.id === this.state.resId ? 'active fw-bold' : '' }">
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="p-1">
|
||||
<t t-if="template.icon" t-out="template.icon"/>
|
||||
<t t-else="">📄</t>
|
||||
</span>
|
||||
<div class="flex-grow-1 text-truncate px-1" t-out="template.template_name"/>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</t>
|
||||
</div>
|
||||
<div class="d-flex flex-column flex-grow-1 min-w-0">
|
||||
<Record
|
||||
resModel="'knowledge.article'"
|
||||
resId="this.state.resId"
|
||||
fieldNames="this.articleTemplateFieldNames"
|
||||
mode="'readonly'"
|
||||
t-slot-scope="slot">
|
||||
<div class="o_knowledge_template_preview flex-grow-1 flex-basis-0" t-ref="scroll-view">
|
||||
<img t-if="slot.record.data.cover_image_url"
|
||||
t-att-src="slot.record.data.cover_image_url"
|
||||
class="o_article_template_cover"/>
|
||||
<div class="o_article_template_container mx-auto p-4">
|
||||
<div t-if="slot.record.data.icon"
|
||||
t-out="slot.record.data.icon"
|
||||
class="o_article_template_emoji"/>
|
||||
<div class="o_article_template_body mt-3 pe-none">
|
||||
<HtmlField t-props="this.getHtmlFieldProps(slot.record)"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-light rounded mx-4 my-3 p-3">
|
||||
<div class="h4 m-0">
|
||||
<span class="pe-1">
|
||||
<t t-if="slot.record.data.icon" t-out="slot.record.data.icon"/>
|
||||
<t t-else="">📄</t>
|
||||
</span>
|
||||
<t t-out="slot.record.data.template_name"/>
|
||||
</div>
|
||||
<p t-if="slot.record.data.template_description" t-out="slot.record.data.template_description" class="m-0 mt-2"/>
|
||||
</div>
|
||||
</Record>
|
||||
</div>
|
||||
</div>
|
||||
<div t-else="" class="o_view_nocontent position-static">
|
||||
<!-- No Content Helper -->
|
||||
<div class="o_nocontent_help">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No template yet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<t t-set-slot="footer">
|
||||
<button t-if="groups.length > 0" t-on-click="onLoadTemplate" class="btn btn-primary" data-hotkey="q">Load Template</button>
|
||||
<button t-on-click="() => this.props.close()" class="btn text-uppercase" data-hotkey="x">Discard</button>
|
||||
</t>
|
||||
</Dialog>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,169 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import {
|
||||
Component,
|
||||
onMounted,
|
||||
} from "@odoo/owl";
|
||||
import {
|
||||
copyOids,
|
||||
getPropNameNode,
|
||||
} from "@knowledge/js/knowledge_utils";
|
||||
|
||||
|
||||
export class AbstractBehavior extends Component {
|
||||
static props = {
|
||||
anchor: { type: Element },
|
||||
// HtmlElement children of the original anchor, before Component
|
||||
// rendering. They are the Elements that were used to extract html
|
||||
// props for this Behavior. This prop usage is to recover the OIDs set
|
||||
// by the editor on those nodes and to remove them from the DOM once
|
||||
// the mounting is done. Cases where the nodes possess a collaborative
|
||||
// OID to recover:
|
||||
// - received a node from a collaborator
|
||||
// - loaded the article as it is stored in the database in edit mode
|
||||
// - switching from readonly to edit mode
|
||||
// - undo/redo in the editor with the Behavior appearing/disappearing
|
||||
// - copy/paste, drag/drop elements with the Behavior
|
||||
blueprintNodes: { type: Array },
|
||||
// Hook for Behavior executed when they are mounted and synchronized
|
||||
// (have the correct oids for their collaborative nodes). Typically, it
|
||||
// is handled by the html_field and its purpose is to synchronously
|
||||
// insert the Behavior anchor at the correct position in the editable.
|
||||
// In edit mode, we cannot let OWL mount a Behavior directly in the
|
||||
// editable as it is an asynchronous process and the state of the html
|
||||
// field can change while the Behavior is being mounted (at that point
|
||||
// it has to be discarded).
|
||||
onReadyToInsertInEditor: { type: Function, optional: true },
|
||||
readonly: { type: Boolean },
|
||||
record: { type: Object },
|
||||
// Element containing all Behavior anchors. It can either be the
|
||||
// OdooEditor.editable, or the readonlyElementRef.el.
|
||||
root: { type: Element },
|
||||
wysiwyg: { type: Object, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.setupAnchor();
|
||||
this.knowledgeCommandsService = useService('knowledgeCommandsService');
|
||||
if (!this.props.readonly) {
|
||||
onMounted(() => {
|
||||
// Reconstruct the blueprint as an Element outside the DOM, but
|
||||
// with the original nodes to keep their OIDs.
|
||||
const blueprint = this.props.anchor.cloneNode(false);
|
||||
// Remove blueprint nodes (not rendered by OWL) from the anchor
|
||||
// in the DOM, but keep them in order to extract useful oids
|
||||
// from them.
|
||||
blueprint.replaceChildren(...this.props.blueprintNodes);
|
||||
// hook for extra rendering steps for Behavior (not done by
|
||||
// OWL templating system).
|
||||
this.extraRender();
|
||||
if (this.props.anchor.oid) {
|
||||
blueprint.oid = this.props.anchor.oid;
|
||||
// copy OIDs from the blueprint in case those OIDs
|
||||
// are used in collaboration.
|
||||
// this step is done before the component is inserted in the
|
||||
// editable (when it is still in the rendering zone)
|
||||
this.synchronizeOids(blueprint);
|
||||
}
|
||||
// the rendering was done outside of the OdooEditor,
|
||||
// onReadyToInsertInEditor contains instructions on how to move
|
||||
// the mounted content in the editable.
|
||||
this.props.onReadyToInsertInEditor();
|
||||
});
|
||||
} else {
|
||||
onMounted(() => {
|
||||
// Remove blueprint nodes (not rendered by OWL) from the anchor
|
||||
// in the dom. It is the best timing to do it since their
|
||||
// removal will occur synchronously with the addition of OWL
|
||||
// nodes.
|
||||
this.props.blueprintNodes.forEach(child => child.remove());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// TECHNICAL
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @abstract
|
||||
* This method is is a hook executed during the onMounted hook, but
|
||||
* before the rendered Behavior is inserted in the editor (in edit mode).
|
||||
* It can be useful to manually insert nodes that can not be managed by OWL
|
||||
* templating system (i.e. because they will be altered by the editor),
|
||||
* before being observed by the editor and/or assigned an oid.
|
||||
* @see ArticlesStructureBehavior for an example.
|
||||
*
|
||||
* Why should this hook be used:
|
||||
* - Some non-editable HTML content managed by the Behavior has to be
|
||||
* shared in collaboration. This means that the content can be updated by
|
||||
* a mutation received through the collaboration (and which is the result
|
||||
* of a collaborator using a feature of the Behavior which changes its
|
||||
* content, i.e. a refresh for the ArticlesStructureBehavior). Owl will
|
||||
* crash if nodes it rendered are not replaced by an Owl patch, so that's
|
||||
* why one should use this this hook to render that content and insert
|
||||
* it where it belong inside the nodes already rendered by Owl for the
|
||||
* first time.
|
||||
* - Ensure that the Behavior has a fully coherent state before being
|
||||
* inserted in the editor and shared in collaboration as a single
|
||||
* mutation.
|
||||
* - Avoid creating multiple "partial" steps that can mess up the history
|
||||
* system (can Undo/Redo each step).
|
||||
*/
|
||||
extraRender() {}
|
||||
|
||||
/**
|
||||
* This method is used to ensure that the correct attributes are set
|
||||
* on the anchor of the Behavior. Attributes could be incorrect for the
|
||||
* following reasons: cleaned by the sanitization (frontend or backend),
|
||||
* attributes from a previous Odoo version, attributes from a drop/paste
|
||||
* of a Behavior which was in another state (i.e. from readonly to editable)
|
||||
*/
|
||||
setupAnchor() {
|
||||
if (!this.props.readonly) {
|
||||
this.props.anchor.setAttribute('contenteditable', 'false');
|
||||
// prevent some interactions with OdooEditor, @see web_editor module
|
||||
this.props.anchor.dataset.oeProtected = "true";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every node in the editor has a unique oid in collaboration. This is to
|
||||
* identify to which node apply a mutation received from a collaborator.
|
||||
* This method reapplies the oids from the blueprint of the Behavior (nodes
|
||||
* present before it was mounted by Owl) to collaborative elements
|
||||
* (=elements under a node with data-oe-protected="false" in a Behavior),
|
||||
* after they were rendered by Owl.
|
||||
*
|
||||
* By default, only html props nodes (data-prop-name) are synchronized,
|
||||
* for other collaborative content, this method should be overridden to
|
||||
* specify which nodes need to be synchronized.
|
||||
*
|
||||
* @param {Element} blueprint node containing all original DOM nodes
|
||||
* from this.props.blueprintNodes.
|
||||
*/
|
||||
synchronizeOids(blueprint) {
|
||||
// extract OIDs from `data-oe-protected='false'` elements which need to
|
||||
// be synchronized in collaborative mode from the blueprint.
|
||||
this.props.anchor.querySelectorAll('[data-oe-protected="false"][data-prop-name]').forEach(node => {
|
||||
const propName = node.dataset.propName;
|
||||
const blueprintElement = getPropNameNode(propName, blueprint);
|
||||
if (!blueprintElement) {
|
||||
return;
|
||||
}
|
||||
// copy OIDs from the blueprint for a collaborative
|
||||
// node of the behavior
|
||||
copyOids(blueprintElement, node);
|
||||
});
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// GETTERS/SETTERS
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
get editor () {
|
||||
return this.props.wysiwyg ? this.props.wysiwyg.odooEditor : undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { AbstractBehavior } from "@knowledge/components/behaviors/abstract_behavior/abstract_behavior";
|
||||
import { AlertDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { useEffect } from "@odoo/owl";
|
||||
|
||||
|
||||
export class ArticleBehavior extends AbstractBehavior {
|
||||
static props = {
|
||||
...AbstractBehavior.props,
|
||||
article_id: { type: Number, optional: false },
|
||||
display_name: { type: String, optional: false },
|
||||
};
|
||||
static template = "knowledge.ArticleBehavior";
|
||||
|
||||
setup () {
|
||||
super.setup();
|
||||
this.actionService = useService('action');
|
||||
this.dialogService = useService('dialog');
|
||||
useEffect(() => {
|
||||
/**
|
||||
* @param {Event} event
|
||||
*/
|
||||
const onLinkClick = async event => {
|
||||
if (!event.currentTarget.closest('.o_knowledge_editor')) {
|
||||
// Use the link normally if not already in Knowledge
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
// TODO: remove when the model correctly asks the htmlField if
|
||||
// it is dirty. This isDirty is necessary because the
|
||||
// /article Behavior can be used outside of Knowledge.
|
||||
await this.props.record.isDirty();
|
||||
this.openArticle();
|
||||
};
|
||||
this.props.anchor.addEventListener('click', onLinkClick);
|
||||
return () => {
|
||||
this.props.anchor.removeEventListener('click', onLinkClick);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// TECHNICAL
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
setupAnchor () {
|
||||
super.setupAnchor();
|
||||
this.props.anchor.setAttribute('target', '_blank');
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// HANDLERS
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
async openArticle () {
|
||||
try {
|
||||
await this.actionService.doAction('knowledge.ir_actions_server_knowledge_home_page', {
|
||||
additionalContext: {
|
||||
res_id: parseInt(this.props.article_id)
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
this.dialogService.add(AlertDialog, {
|
||||
title: _t('Error'),
|
||||
body: _t("This article was deleted or you don't have the rights to access it."),
|
||||
confirmLabel: _t('Ok'),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<!-- /article template -->
|
||||
<t t-name="knowledge.ArticleBehavior">
|
||||
<t t-out="props.display_name"/>
|
||||
</t>
|
||||
</templates>
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { useService } from '@web/core/utils/hooks';
|
||||
import { Dialog } from '@web/core/dialog/dialog';
|
||||
import { SelectMenu } from '@web/core/select_menu/select_menu';
|
||||
import { DropdownItem } from '@web/core/dropdown/dropdown_item';
|
||||
import { Component, useEffect, onWillStart, useRef, useState } from '@odoo/owl';
|
||||
|
||||
export class ArticleSelectionBehaviorDialog extends Component {
|
||||
|
||||
static template = 'knowledge.ArticleSelectionBehaviorDialog';
|
||||
static components = { Dialog, DropdownItem, SelectMenu };
|
||||
static props = {
|
||||
articleSelected: Function,
|
||||
close: Function,
|
||||
confirmLabel: String,
|
||||
title: String,
|
||||
parentArticleId: { type: Number, optional: true },
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
setup() {
|
||||
super.setup();
|
||||
this.orm = useService('orm');
|
||||
this.userService = useService('user');
|
||||
this.placeholderLabel = _t('Choose an Article...');
|
||||
this.toggler = useRef('togglerRef');
|
||||
this.state = useState({
|
||||
selectedArticleName: false,
|
||||
knowledgeArticles: [],
|
||||
createLabel: ''
|
||||
});
|
||||
|
||||
//autofocus
|
||||
useEffect((toggler) => {
|
||||
toggler.click();
|
||||
}, () => [this.toggler.el]);
|
||||
|
||||
onWillStart(async () => {
|
||||
await this.fetchArticles();
|
||||
this.state.isInternalUser = await this.userService.hasGroup('base.group_user');
|
||||
});
|
||||
}
|
||||
|
||||
async createKnowledgeArticle(label) {
|
||||
const articleId = await this.orm.call(
|
||||
'knowledge.article',
|
||||
'article_create',
|
||||
[],
|
||||
{title: label, parent_id: this.props.parentArticleId}
|
||||
);
|
||||
this.props.articleSelected({articleId: articleId, displayName: `📄 ${label}`});
|
||||
this.props.close();
|
||||
if (this.props.parentArticleId) {
|
||||
this.env.bus.trigger('knowledge.sidebar.insertNewArticle', {
|
||||
articleId: articleId,
|
||||
name: label,
|
||||
icon: '📄',
|
||||
parentId: this.props.parentArticleId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fetchArticles(searchValue) {
|
||||
this.state.createLabel = _t('Create "%s"', searchValue);
|
||||
const domain = [
|
||||
['user_has_access', '=', true],
|
||||
['is_template', '=', false]
|
||||
];
|
||||
if (searchValue) {
|
||||
domain.push(['name', '=ilike', `%${searchValue}%`]);
|
||||
}
|
||||
const knowledgeArticles = await this.orm.searchRead(
|
||||
'knowledge.article',
|
||||
domain,
|
||||
['id', 'display_name', 'root_article_id'], {
|
||||
limit: 20
|
||||
});
|
||||
this.state.knowledgeArticles = knowledgeArticles.map(({ id, display_name, root_article_id }) => {
|
||||
return {
|
||||
value: {
|
||||
articleId: id,
|
||||
rootArticleName: root_article_id[0] !== id ? root_article_id[1] : ''
|
||||
},
|
||||
label: display_name
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async selectArticle(value) {
|
||||
this.selectedArticle = this.state.knowledgeArticles.find(knowledgeArticle => knowledgeArticle.value.articleId === value.articleId);
|
||||
this.state.selectedArticleName = this.selectedArticle.label;
|
||||
}
|
||||
|
||||
confirmArticleSelection() {
|
||||
this.props.articleSelected({articleId: this.selectedArticle.value.articleId, displayName: this.selectedArticle.label});
|
||||
this.props.close();
|
||||
}
|
||||
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<t t-name="knowledge.ArticleSelectionBehaviorDialog">
|
||||
<Dialog size="'md'" title="this.props.title">
|
||||
<div class="m-0">
|
||||
<SelectMenu
|
||||
onInput.bind="fetchArticles"
|
||||
onSelect.bind="selectArticle"
|
||||
choices="this.state.knowledgeArticles || []"
|
||||
togglerClass="'form-control o_knowledge_select_menu show'"
|
||||
class="'o_knowledge_select_menu fst-normal fw-normal show'"
|
||||
required="true">
|
||||
<span t-ref="togglerRef" class="fw-lighter"><t t-out="state.selectedArticleName || this.placeholderLabel"/></span>
|
||||
<t t-set-slot="choice" t-slot-scope="choice">
|
||||
<span>
|
||||
<t t-out="choice.data.label"/>
|
||||
<span t-if="choice.data.value and choice.data.value.rootArticleName" class="text-muted small">
|
||||
- <t t-out="choice.data.value.rootArticleName"/>
|
||||
</span>
|
||||
</span>
|
||||
</t>
|
||||
<t t-set-slot="bottomArea" t-slot-scope="select">
|
||||
<DropdownItem
|
||||
t-if="select.data.searchValue and this.state.isInternalUser"
|
||||
class="'o_select_menu_sticky o_select_menu_item position-sticky text-italic bottom-0 text-primary text-start'"
|
||||
onSelected="() => this.createKnowledgeArticle(select.data.searchValue)"
|
||||
>
|
||||
<t t-out="this.state.createLabel"/>
|
||||
</DropdownItem>
|
||||
</t>
|
||||
</SelectMenu>
|
||||
</div>
|
||||
<t t-set-slot="footer">
|
||||
<button t-attf-class="btn btn-primary #{!this.state.selectedArticleName ? 'disabled' : ''}" t-out="this.props.confirmLabel"
|
||||
t-on-click="this.confirmArticleSelection.bind(this)"/>
|
||||
<button class="btn btn-secondary" t-on-click="this.props.close.bind(this)">
|
||||
Cancel
|
||||
</button>
|
||||
</t>
|
||||
</Dialog>
|
||||
</t>
|
||||
</templates>
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { AbstractBehavior } from "@knowledge/components/behaviors/abstract_behavior/abstract_behavior";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { renderToMarkup } from "@web/core/utils/render";
|
||||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
onMounted,
|
||||
onWillStart,
|
||||
} from "@odoo/owl";
|
||||
import {
|
||||
BehaviorToolbar,
|
||||
BehaviorToolbarButton,
|
||||
} from "@knowledge/components/behaviors/behavior_toolbar/behavior_toolbar";
|
||||
import { useRefWithSingleCollaborativeChild } from "@knowledge/js/knowledge_utils";
|
||||
|
||||
|
||||
/**
|
||||
* It creates a listing of children of this article.
|
||||
*
|
||||
* It is used by 2 different commands:
|
||||
* - /index that only list direct children
|
||||
* - /outline that lists all children
|
||||
*/
|
||||
export class ArticlesStructureBehavior extends AbstractBehavior {
|
||||
static components = {
|
||||
BehaviorToolbar,
|
||||
BehaviorToolbarButton,
|
||||
};
|
||||
static props = {
|
||||
...AbstractBehavior.props,
|
||||
content: { type: Object, optional: true },
|
||||
};
|
||||
static template = "knowledge.ArticlesStructureBehavior";
|
||||
|
||||
setup () {
|
||||
super.setup();
|
||||
this.orm = useService('orm');
|
||||
this.actionService = useService('action');
|
||||
this.childrenSelector = 'o_knowledge_articles_structure_children_only';
|
||||
this.state = useState({
|
||||
// Used for the loading animation on the refresh button
|
||||
refreshing: false,
|
||||
// Specify when the content is ready to be inserted
|
||||
changeContent: false,
|
||||
// Used to change the display value of the "Show All" button
|
||||
showAllChildren: !this.props.content || !this.props.content.includes(this.childrenSelector),
|
||||
});
|
||||
// Used during the loading of a new content (for rpc queries and
|
||||
// rendering)
|
||||
this.showAllChildren = this.state.showAllChildren;
|
||||
// Register changes in Elements of the content, the purpose is to
|
||||
// maintain maximum one OL element as the only child of content.
|
||||
this.articlesStructureContent = useRefWithSingleCollaborativeChild(
|
||||
"articlesStructureContent",
|
||||
(element) => {
|
||||
if (element) {
|
||||
// The content OL element has the class which tells if it
|
||||
// contains articles sub-children or not, update the state in
|
||||
// consequence.
|
||||
this.state.showAllChildren = !element.classList.contains(this.childrenSelector);
|
||||
} else {
|
||||
// The default state when the content is empty is to show
|
||||
// all articles sub-children.
|
||||
this.state.showAllChildren = true;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onWillStart(async () => {
|
||||
// Populate content with the prop coming from the blueprint if
|
||||
// possible.
|
||||
this.content = this.props.content || await this._renderArticlesStructure();
|
||||
});
|
||||
|
||||
if (!this.props.readonly) {
|
||||
let mounted = false;
|
||||
useEffect(() => {
|
||||
// Nothing to do onMounted
|
||||
if (!mounted) {
|
||||
mounted = true;
|
||||
return;
|
||||
}
|
||||
// Update the content when clicking on a button of the toolbar
|
||||
if (this.state.changeContent) {
|
||||
this._appendArticlesStructureContent();
|
||||
this.editor.historyStep();
|
||||
this.state.changeContent = false;
|
||||
}
|
||||
}, () => [this.state.changeContent]);
|
||||
} else {
|
||||
onMounted(() => {
|
||||
this._appendArticlesStructureContent();
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const onClick = this._onArticleLinkClick.bind(this);
|
||||
const links = this.props.anchor.querySelectorAll('.o_knowledge_article_structure_link');
|
||||
links.forEach(link => link.addEventListener('click', onClick));
|
||||
return () => {
|
||||
links.forEach(link => link.removeEventListener('click', onClick));
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const onDrop = event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
this.props.anchor.addEventListener('drop', onDrop);
|
||||
return () => {
|
||||
this.props.anchor.removeEventListener('drop', onDrop);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// TECHNICAL
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
extraRender() {
|
||||
super.extraRender();
|
||||
this._appendArticlesStructureContent();
|
||||
// Migration to the new system (the childrenSelector class is
|
||||
// used on the content node instead of the anchor).
|
||||
if (this.props.anchor.classList.contains(this.childrenSelector)) {
|
||||
this.props.anchor.classList.toggle(this.childrenSelector);
|
||||
this.articlesStructureContent.el.querySelectorAll(':scope > ol').forEach((ol) => {
|
||||
ol.classList.toggle(this.childrenSelector);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// BUSINESS
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
_appendArticlesStructureContent() {
|
||||
const parser = new DOMParser();
|
||||
// this.content always comes from the database, or from a sanitized
|
||||
// collaborative step (DOMPurify).
|
||||
this.articlesStructureContent.el.replaceChildren(...parser.parseFromString(this.content, 'text/html').body.children);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the flat search_read result into a parent/children articles hierarchy.
|
||||
*
|
||||
* @param {Integer} parentId
|
||||
* @param {Array} allArticles
|
||||
* @returns {Array[Object]} articles structure
|
||||
*/
|
||||
_buildArticlesStructure (parentId, allArticles) {
|
||||
const articles = [];
|
||||
for (const article of allArticles) {
|
||||
if (article.parent_id && article.parent_id === parentId) {
|
||||
articles.push({
|
||||
id: article.id,
|
||||
name: article.display_name,
|
||||
child_ids: this._buildArticlesStructure(article.id, allArticles),
|
||||
});
|
||||
}
|
||||
}
|
||||
return articles;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Array[Object]}
|
||||
*/
|
||||
async _fetchAllArticles (articleId) {
|
||||
const domain = [
|
||||
['parent_id', !this.showAllChildren ? '=' : 'child_of', articleId],
|
||||
['is_article_item', '=', false]
|
||||
];
|
||||
const { records } = await this.orm.webSearchRead('knowledge.article', domain, {
|
||||
specification: {
|
||||
display_name: {},
|
||||
parent_id: {},
|
||||
},
|
||||
order: 'sequence',
|
||||
});
|
||||
return records;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
async _renderArticlesStructure () {
|
||||
const articleId = this.props.record.resId;
|
||||
const allArticles = await this._fetchAllArticles(articleId);
|
||||
return renderToMarkup('knowledge.ArticlesStructureContent', {
|
||||
'articles': this._buildArticlesStructure(articleId, allArticles),
|
||||
'showAllChildren': this.showAllChildren,
|
||||
});
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// HANDLERS
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Opens the article in the side tree menu.
|
||||
*
|
||||
* @param {Event} event
|
||||
*/
|
||||
async _onArticleLinkClick (event) {
|
||||
event.preventDefault();
|
||||
this.actionService.doAction('knowledge.ir_actions_server_knowledge_home_page', {
|
||||
additionalContext: {
|
||||
res_id: parseInt(event.target.getAttribute('data-oe-nodeid'))
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} event
|
||||
*/
|
||||
async _onRefreshBtnClick (event) {
|
||||
event.stopPropagation();
|
||||
// Patch the Behavior to show that it is loading.
|
||||
this.state.refreshing = true;
|
||||
this.content = await this._renderArticlesStructure();
|
||||
// Patch the Behavior to show that the loading is finished and the
|
||||
// content is ready
|
||||
this.state.refreshing = false;
|
||||
this.state.changeContent = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} event
|
||||
*/
|
||||
async _onSwitchMode (event) {
|
||||
this.showAllChildren = !this.state.showAllChildren;
|
||||
await this._onRefreshBtnClick(event);
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<!-- /index and /outline template -->
|
||||
<t t-name="knowledge.ArticlesStructureBehavior">
|
||||
<t t-set="show_less">Show Less</t>
|
||||
<t t-set="show_all">Show All</t>
|
||||
<t t-set="switch_mode">Switch Mode</t>
|
||||
<t t-set="refresh">Refresh</t>
|
||||
<t t-set="update">Update</t>
|
||||
<BehaviorToolbar>
|
||||
<t t-set-slot="buttons">
|
||||
<BehaviorToolbarButton
|
||||
hidden="props.readonly"
|
||||
icon="state.showAllChildren ? 'fa-eye-slash' : 'fa-eye'"
|
||||
label="state.showAllChildren ? show_less : show_all"
|
||||
onClick.bind="_onSwitchMode"
|
||||
title="switch_mode"
|
||||
/>
|
||||
<BehaviorToolbarButton
|
||||
hidden="props.readonly"
|
||||
icon="['fa-refresh', this.state.refreshing || this.state.changeContent ? 'fa-spin' : ''].join(' ')"
|
||||
label="refresh"
|
||||
onClick.bind="_onRefreshBtnClick"
|
||||
title="update"
|
||||
/>
|
||||
</t>
|
||||
</BehaviorToolbar>
|
||||
<div class="o_knowledge_articles_structure_content" data-oe-protected="false"
|
||||
data-prop-name="content" t-ref="articlesStructureContent"/>
|
||||
</t>
|
||||
<!-- The content is rendered as a QWeb template to avoid Owl empty text
|
||||
nodes between t-instructions that could break collaboration as it is not
|
||||
protected from the editor observer.
|
||||
-->
|
||||
<t t-name="knowledge.ArticlesStructureContent">
|
||||
<i t-if="!articles or articles.length === 0">Nest articles under this one to generate a structure</i>
|
||||
<ol t-attf-class="mb-0 #{showAllChildren ? '' : 'o_knowledge_articles_structure_children_only'}">
|
||||
<t t-foreach="articles" t-as="article" t-key="article_index">
|
||||
<li>
|
||||
<!-- Use 'data-oe-nodeid' as we need something that is not removed during sanitizing -->
|
||||
<a t-attf-href="/knowledge/article/#{article.id}" t-att-data-oe-nodeid="article.id"
|
||||
class="d-block w-100 ps-1 border-0 o_knowledge_article_structure_link"
|
||||
contenteditable="false" t-out="article.name">
|
||||
</a>
|
||||
</li>
|
||||
<li t-if="article.child_ids and article.child_ids.length !== 0">
|
||||
<t t-call="knowledge.ArticlesStructureContent">
|
||||
<t t-set="articles" t-value="article.child_ids"/>
|
||||
</t>
|
||||
</li>
|
||||
</t>
|
||||
</ol>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,31 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import {
|
||||
Component,
|
||||
} from "@odoo/owl";
|
||||
import { useForwardRefToParent } from "@web/core/utils/hooks";
|
||||
|
||||
export class BehaviorToolbar extends Component {
|
||||
static props = {
|
||||
buttonsGroupClass: { type: String, optional: true },
|
||||
slots: Object,
|
||||
};
|
||||
static template = "knowledge.BehaviorToolbar";
|
||||
}
|
||||
|
||||
export class BehaviorToolbarButton extends Component {
|
||||
static props = {
|
||||
buttonRef: { type: Function, optional: true },
|
||||
hidden: { type: Boolean, optional: true },
|
||||
icon: { type: String, optional: true },
|
||||
label: String,
|
||||
name: { type: String, optional: true },
|
||||
onClick: Function,
|
||||
title: { type: String, optional: true},
|
||||
};
|
||||
static template = "knowledge.BehaviorToolbarButton";
|
||||
|
||||
setup() {
|
||||
useForwardRefToParent("buttonRef");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<t t-name="knowledge.BehaviorToolbar">
|
||||
<div class="o_knowledge_toolbar d-flex justify-content-end align-items-center" data-oe-transient-content="true">
|
||||
<div t-attf-class="btn-group d-flex flex-wrap #{ props.buttonsGroupClass ? props.buttonsGroupClass : '' }">
|
||||
<t t-slot="buttons"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-name="knowledge.BehaviorToolbarButton">
|
||||
<button t-if="!props.hidden" t-ref="buttonRef" t-on-click="props.onClick" t-att-name="props.name" t-att-title="props.title"
|
||||
class="btn btn-primary btn-link btn-sm border-0 py-0 text-break text-start text-o-color-2 text-capitalize fw-bold border-transparent bg-transparent">
|
||||
<i t-if="props.icon" t-attf-class="fa #{props.icon} me-1"/>
|
||||
<span class="o_knowledge_toolbar_button_text" t-out="props.label"/>
|
||||
</button>
|
||||
</t>
|
||||
</templates>
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { AbstractBehavior } from "@knowledge/components/behaviors/abstract_behavior/abstract_behavior";
|
||||
import { EmbeddedViewManager } from "@knowledge/components/behaviors/embedded_view_behavior/embedded_view_manager";
|
||||
import { makeContext } from "@web/core/context";
|
||||
import {
|
||||
decodeDataBehaviorProps,
|
||||
encodeDataBehaviorProps,
|
||||
setIntersectionObserver
|
||||
} from "@knowledge/js/knowledge_utils";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { uuid } from "@web/views/utils";
|
||||
import {
|
||||
onError,
|
||||
onMounted,
|
||||
onWillDestroy,
|
||||
useExternalListener,
|
||||
useState,
|
||||
useSubEnv } from "@odoo/owl";
|
||||
|
||||
/**
|
||||
* This component will have the responsibility to load the embedded view lazily
|
||||
* when it becomes visible on screen. The component will also have the responsibility
|
||||
* to handle errors that may occur when loading an embedded view.
|
||||
*/
|
||||
export class EmbeddedViewBehavior extends AbstractBehavior {
|
||||
static components = {
|
||||
EmbeddedViewManager,
|
||||
};
|
||||
static props = {
|
||||
...AbstractBehavior.props,
|
||||
action_help: { type: Object, optional: true},
|
||||
action_xml_id: { type: String, optional: true },
|
||||
act_window: { type: Object, optional: true },
|
||||
additionalViewProps: { type: Object, optional: true},
|
||||
context: { type: Object, optional: true },
|
||||
display_name: { type: String, optional: true },
|
||||
embedded_view_id: { type: String, optional: true },
|
||||
view_type: { type: String },
|
||||
};
|
||||
static template = "knowledge.EmbeddedViewBehavior";
|
||||
|
||||
setup () {
|
||||
super.setup();
|
||||
this.actionService = useService('action');
|
||||
this.uiService = useService('ui');
|
||||
this.state = useState({
|
||||
waiting: true,
|
||||
error: false
|
||||
});
|
||||
|
||||
let embeddedViewId = this.props.embedded_view_id;
|
||||
if (!embeddedViewId) {
|
||||
embeddedViewId = uuid();
|
||||
const { anchor } = this.props;
|
||||
anchor.dataset.behaviorProps = encodeDataBehaviorProps(Object.assign(
|
||||
decodeDataBehaviorProps(anchor.dataset.behaviorProps),
|
||||
{ embedded_view_id: embeddedViewId }
|
||||
));
|
||||
}
|
||||
|
||||
this.knowledgeEmbeddedViewId = embeddedViewId;
|
||||
useSubEnv({
|
||||
knowledgeArticleUserCanWrite: this.props.record.data.user_can_write,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
const { anchor, root } = this.props;
|
||||
if (root.contains(anchor)) {
|
||||
this.setupIntersectionObserver();
|
||||
} else {
|
||||
// If the anchor is not already in the editable, it means that
|
||||
// it was mounted in a d-none element and is waiting
|
||||
// to be inserted. The intersectionObserver must wait for the
|
||||
// anchor to be in the editable before being set up, because
|
||||
// it will not detect that it is visible if the insertion
|
||||
// moves the anchor from the d-none element to a visible
|
||||
// sector of the editable.
|
||||
this.insertionObserver = new MutationObserver(mutationList => {
|
||||
const isAdded = mutationList.find(mutation => {
|
||||
return Array.from(mutation.addedNodes).find(node => node === anchor);
|
||||
});
|
||||
if (isAdded) {
|
||||
this.insertionObserver.disconnect();
|
||||
this.setupIntersectionObserver();
|
||||
}
|
||||
});
|
||||
this.insertionObserver.observe(root, {childList: true});
|
||||
}
|
||||
/**
|
||||
* Capturing the events occuring in the embedded view to prevent the
|
||||
* default behavior of the editor.
|
||||
* The event is cloned and dispatched again above the editable.
|
||||
* This essentially "skips" the editor listener and propagates the event above it.
|
||||
*/
|
||||
const bypassEditorEventListeners = event => {
|
||||
const parent = this.props.root && this.props.root.parentElement;
|
||||
event.stopPropagation();
|
||||
if (parent) {
|
||||
const clonedEvent = new event.constructor(event.type, event);
|
||||
parent.dispatchEvent(clonedEvent);
|
||||
}
|
||||
};
|
||||
anchor.addEventListener('keydown', bypassEditorEventListeners);
|
||||
anchor.addEventListener('keyup', bypassEditorEventListeners); // power box
|
||||
anchor.addEventListener('input', bypassEditorEventListeners);
|
||||
anchor.addEventListener('beforeinput', bypassEditorEventListeners);
|
||||
anchor.addEventListener('paste', bypassEditorEventListeners);
|
||||
anchor.addEventListener('drop', bypassEditorEventListeners);
|
||||
});
|
||||
|
||||
onWillDestroy(() => {
|
||||
if (this.observer) {
|
||||
this.observer.disconnect();
|
||||
}
|
||||
if (this.insertionObserver) {
|
||||
this.insertionObserver.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
onError(error => {
|
||||
console.error(error);
|
||||
this.state.error = true;
|
||||
});
|
||||
}
|
||||
/**
|
||||
* This function enables us to adds the desired attributes to the anchor of the embedded view
|
||||
* before it is rendered.
|
||||
* We are adding tabindex="-1" to the anchor because this attribute is needed to capture the
|
||||
* 'focusin' and 'focusout' events.
|
||||
* In these events we are using activateElement/deactivateElement:
|
||||
*
|
||||
* `activateElement` is used to set the anchor as an active element in the ui service, this enables
|
||||
* us to contain the events inside the embedded view when it has the focus.
|
||||
*
|
||||
* `deactivateElement` removes the anchor as an active element, leaving only the document as active
|
||||
* and we come back to the default behavior of the document handling all the events.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
setupAnchor() {
|
||||
super.setupAnchor();
|
||||
this.props.anchor.setAttribute('tabindex', '-1');
|
||||
useExternalListener(this.props.anchor, 'focusin', () => {
|
||||
if (!this.props.anchor.contains(this.uiService.activeElement)) {
|
||||
this.uiService.activateElement(this.props.anchor);
|
||||
}
|
||||
});
|
||||
useExternalListener(this.props.anchor, 'focusout', (event) => {
|
||||
if (!this.props.anchor.contains(event.relatedTarget)) {
|
||||
this.uiService.deactivateElement(this.props.anchor);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// TECHNICAL
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
setupIntersectionObserver() {
|
||||
this.observer = setIntersectionObserver(this.props.anchor, async () => {
|
||||
await this.loadData();
|
||||
this.state.waiting = false;
|
||||
});
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// GETTERS/SETTERS
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the title of the embedded view.
|
||||
* @returns {String}
|
||||
*/
|
||||
getTitle () {
|
||||
return this.embeddedViewManagerProps.action.display_name ||
|
||||
this.embeddedViewManagerProps.action.name ||
|
||||
'';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the title of the embedded view.
|
||||
* @param {String} name
|
||||
*/
|
||||
setTitle (name) {
|
||||
const behaviorProps = decodeDataBehaviorProps(this.props.anchor.getAttribute('data-behavior-props'));
|
||||
if (behaviorProps.act_window) {
|
||||
behaviorProps.act_window.name = name;
|
||||
behaviorProps.act_window.display_name = name;
|
||||
}
|
||||
if (behaviorProps.display_name) {
|
||||
behaviorProps.display_name = name;
|
||||
}
|
||||
this.props.anchor.dataset.behaviorProps = encodeDataBehaviorProps(behaviorProps);
|
||||
this.embeddedViewManagerProps.action.name = name;
|
||||
this.embeddedViewManagerProps.action.display_name = name;
|
||||
const title = this.props.anchor.querySelector('.o_control_panel .o_last_breadcrumb_item.active span');
|
||||
if (title) {
|
||||
title.textContent = name;
|
||||
}
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// BUSINESS
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
async loadData () {
|
||||
const context = makeContext([this.props.context || {}, {
|
||||
knowledgeEmbeddedViewId: this.knowledgeEmbeddedViewId
|
||||
}]);
|
||||
try {
|
||||
const action = await this.actionService.loadAction(
|
||||
this.props.act_window || this.props.action_xml_id,
|
||||
context
|
||||
);
|
||||
if (action.type !== "ir.actions.act_window") {
|
||||
this.state.error = true;
|
||||
return;
|
||||
}
|
||||
if (this.props.display_name) {
|
||||
action.name = this.props.display_name;
|
||||
action.display_name = this.props.display_name;
|
||||
}
|
||||
if (this.props.action_help) {
|
||||
action.help = this.props.action_help;
|
||||
}
|
||||
this.embeddedViewManagerProps = {
|
||||
anchor: this.props.anchor,
|
||||
action,
|
||||
additionalViewProps: this.props.additionalViewProps,
|
||||
context,
|
||||
viewType: this.props.view_type,
|
||||
setTitle: this.setTitle.bind(this),
|
||||
getTitle: this.getTitle.bind(this),
|
||||
readonly: this.props.readonly,
|
||||
record: this.props.record,
|
||||
};
|
||||
} catch {
|
||||
this.state.error = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<t t-name="knowledge.EmbeddedViewBehavior">
|
||||
<div t-if="state.error" class="o_knowledge_embedded_view_placeholder d-flex flex-column flex-grow-1 position-relative bg-white border"
|
||||
data-oe-transient-content="true">
|
||||
<div class="d-flex border-bottom p-5 pb-0">
|
||||
<div class="mb-3 p-3 bg-light w-25 me-auto"/>
|
||||
<div class="mb-3 p-3 bg-light w-50 border-bottom"/>
|
||||
</div>
|
||||
<div class="bg-light flex-grow-1 p-3 w-100"></div>
|
||||
<div class="o_knowledge_embedded_view_placeholder_cover d-flex flex-column align-items-center justify-content-center">
|
||||
<p class="o_view_nocontent_empty_folder m-0"></p>
|
||||
<p class="display-6">Something went wrong!</p>
|
||||
<p>The view does not exist or you are not allowed to access to it.</p>
|
||||
</div>
|
||||
</div>
|
||||
<t t-elif="state.waiting">
|
||||
<div class="p-3 text-center">
|
||||
<i class="fa fa-circle-o-notch fa-2x fa-spin" title="loader" role="img"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<EmbeddedViewManager t-props="this.embeddedViewManagerProps"/>
|
||||
</t>
|
||||
<div t-if="props.action_help" class="o_knowledge_content d-none" data-prop-name="action_help" data-oe-protected="false"
|
||||
contenteditable="false">
|
||||
<t t-out="props.action_help"/>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
+337
@@ -0,0 +1,337 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { CallbackRecorder } from "@web/webclient/actions/action_hook";
|
||||
import { getDefaultConfig } from "@web/views/view";
|
||||
import { EmbeddedView } from "@knowledge/views/embedded_view";
|
||||
import { ItemCalendarPropsDialog } from "@knowledge/components/item_calendar_props_dialog/item_calendar_props_dialog";
|
||||
import { PromptEmbeddedViewNameDialog } from "@knowledge/components/prompt_embedded_view_name_dialog/prompt_embedded_view_name_dialog";
|
||||
import { useOwnDebugContext } from "@web/core/debug/debug_context";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import {
|
||||
Component,
|
||||
onWillStart,
|
||||
useState,
|
||||
useSubEnv
|
||||
} from "@odoo/owl";
|
||||
import { decodeDataBehaviorProps, encodeDataBehaviorProps } from "@knowledge/js/knowledge_utils";
|
||||
import {
|
||||
BehaviorToolbar,
|
||||
BehaviorToolbarButton,
|
||||
} from "@knowledge/components/behaviors/behavior_toolbar/behavior_toolbar";
|
||||
|
||||
|
||||
const EMBEDDED_VIEW_LIMITS = {
|
||||
kanban: 20,
|
||||
list: 40,
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper for the embedded view, manage the toolbar and the embedded view props
|
||||
*/
|
||||
export class EmbeddedViewManager extends Component {
|
||||
static components = {
|
||||
BehaviorToolbar,
|
||||
BehaviorToolbarButton,
|
||||
};
|
||||
static props = {
|
||||
action: { type: Object },
|
||||
additionalViewProps: { type: Object, optional: true },
|
||||
anchor: { type: HTMLElement },
|
||||
context: { type: Object },
|
||||
getTitle: { type: Function },
|
||||
setTitle: { type: Function },
|
||||
readonly: { type: Boolean },
|
||||
record: { type: Object },
|
||||
viewType: { type: String },
|
||||
};
|
||||
static template = 'knowledge.EmbeddedViewManager';
|
||||
|
||||
setup() {
|
||||
// allow access to the SearchModel exported state which contain facets
|
||||
this.__getGlobalState__ = new CallbackRecorder();
|
||||
|
||||
this.actionService = useService('action');
|
||||
this.dialogService = useService('dialog');
|
||||
this.notification = useService("notification");
|
||||
this.embedViewsFilterService = useService('knowledgeEmbedViewsFilters');
|
||||
|
||||
useOwnDebugContext(); // define a debug context when the developer mode is enable
|
||||
const config = {
|
||||
...getDefaultConfig(),
|
||||
disableSearchBarAutofocus: true,
|
||||
};
|
||||
|
||||
// In addition to the base viewProps (view type, model, domain, ...),
|
||||
// embedded views can have additionalViewProps that can be edited by
|
||||
// the end user, that are stored in the behaviorProps and that are used
|
||||
// to load the view.
|
||||
// These are for example currently used by the item calendar view to
|
||||
// store the start and end date properties that the model should use.
|
||||
this.state = useState({additionalViewProps: this.props.additionalViewProps});
|
||||
|
||||
/**
|
||||
* @param {ViewType} viewType
|
||||
* @param {Object} [props={}]
|
||||
*/
|
||||
const switchView = (viewType, props = {}) => {
|
||||
if (this.action.type !== "ir.actions.act_window") {
|
||||
throw new Error('Can not open the view: The action is not an "ir.actions.act_window"');
|
||||
}
|
||||
if (props.resId) {
|
||||
this.action.res_id = props.resId;
|
||||
}
|
||||
this.action.globalState = this.getEmbeddedViewGlobalState();
|
||||
this.actionService.doAction(this.action, {
|
||||
viewType: viewType,
|
||||
});
|
||||
};
|
||||
|
||||
const services = this.env.services;
|
||||
const extendedServices = Object.create(services);
|
||||
extendedServices.action = Object.create(services.action);
|
||||
extendedServices.action.switchView = switchView;
|
||||
|
||||
useSubEnv({
|
||||
config,
|
||||
isEmbeddedView: true,
|
||||
services: extendedServices,
|
||||
});
|
||||
onWillStart(this.onWillStart.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit the props used to render the item calendar
|
||||
*/
|
||||
editItemCalendarProps() {
|
||||
this.dialogService.add(ItemCalendarPropsDialog, {
|
||||
isNew: false,
|
||||
name: this.props.getTitle(),
|
||||
saveItemCalendarProps: async (name, itemCalendarProps) => {
|
||||
this.props.setTitle(name);
|
||||
this.state.additionalViewProps.itemCalendarProps = itemCalendarProps;
|
||||
const behaviorProps = decodeDataBehaviorProps(this.props.anchor.dataset.behaviorProps);
|
||||
behaviorProps.additionalViewProps.itemCalendarProps = itemCalendarProps;
|
||||
this.props.anchor.dataset.behaviorProps = encodeDataBehaviorProps(behaviorProps);
|
||||
},
|
||||
knowledgeArticleId: this.embeddedViewProps.context.active_id,
|
||||
...this.state.additionalViewProps.itemCalendarProps,
|
||||
});
|
||||
}
|
||||
|
||||
get allEmbeddedViewProps() {
|
||||
return {...this.embeddedViewProps, additionalViewProps: {...this.state.additionalViewProps}};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the SearchModel state of the embedded view
|
||||
*
|
||||
* @returns {Object} globalState
|
||||
*/
|
||||
getEmbeddedViewGlobalState() {
|
||||
const callbacks = this.__getGlobalState__.callbacks;
|
||||
let globalState;
|
||||
if (callbacks.length) {
|
||||
globalState = callbacks.reduce((res, callback) => {
|
||||
return { ...res, ...callback() };
|
||||
}, {});
|
||||
}
|
||||
return { searchModel: globalState && globalState.searchModel };
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the search favorite in the view arch.
|
||||
*/
|
||||
async onSaveKnowledgeFavorite(favorite) {
|
||||
if (this.props.readonly) {
|
||||
this.notification.add(_t("You can not save favorite on this article"), {
|
||||
type: "danger",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const data = decodeDataBehaviorProps(this.props.anchor.getAttribute("data-behavior-props"));
|
||||
const favorites = data.favorites || [];
|
||||
favorites.push(favorite);
|
||||
data.favorites = favorites;
|
||||
this.props.anchor.setAttribute("data-behavior-props", encodeDataBehaviorProps(data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the search favorite from the view arch.
|
||||
*/
|
||||
async onDeleteKnowledgeFavorite(searchItem) {
|
||||
if (this.props.readonly) {
|
||||
this.notification.add(_t("You can not delete favorite from this article"), {
|
||||
type: "danger",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const data = decodeDataBehaviorProps(this.props.anchor.getAttribute("data-behavior-props"));
|
||||
const favorites = data.favorites || [];
|
||||
data.favorites = favorites.filter((favorite) => favorite.name != searchItem.description);
|
||||
this.props.anchor.setAttribute("data-behavior-props", encodeDataBehaviorProps(data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover the action from its parsed state (attrs of the Behavior block)
|
||||
* and setup the embedded view props
|
||||
*/
|
||||
onWillStart () {
|
||||
const { action, context, viewType } = this.props;
|
||||
const contextKeyOptionalFields = context.keyOptionalFields;
|
||||
if (contextKeyOptionalFields && !contextKeyOptionalFields.includes(context.knowledgeEmbeddedViewId)) {
|
||||
// If the key from the context does not contain the embeddedViewId this means that we are inserting
|
||||
// a brand new embed. Thus we are adding the optionalFields stored with contextKeyOptionalFields
|
||||
// inside the localStorage with a key that contains the embeddedViewId.
|
||||
// This way when we are rendering the embedded view and its fullscreen version we are using the correct
|
||||
// key and rendering the correct fields.
|
||||
const optionalFields = localStorage.getItem(contextKeyOptionalFields);
|
||||
if (optionalFields !== null && !localStorage.getItem(contextKeyOptionalFields+`,${context.knowledgeEmbeddedViewId}`)) {
|
||||
localStorage.setItem(contextKeyOptionalFields+`,${context.knowledgeEmbeddedViewId}`, optionalFields);
|
||||
context.keyOptionalFields = contextKeyOptionalFields+`,${context.knowledgeEmbeddedViewId}`;
|
||||
}
|
||||
}
|
||||
this.env.config.setDisplayName(action.display_name);
|
||||
this.env.config.views = action.views;
|
||||
const viewProps = {
|
||||
resModel: action.res_model,
|
||||
context: context,
|
||||
domain: action.domain || [],
|
||||
type: viewType,
|
||||
loadIrFilters: true,
|
||||
loadActionMenus: true,
|
||||
__getGlobalState__: this.__getGlobalState__,
|
||||
globalState: { searchModel: context.knowledge_search_model_state },
|
||||
/**
|
||||
* @param {integer} recordId
|
||||
*/
|
||||
selectRecord: recordId => {
|
||||
const [formViewId] = this.action.views.find((view) => {
|
||||
return view[1] === 'form';
|
||||
}) || [false];
|
||||
this.saveEmbedViewFilters();
|
||||
this.actionService.doAction({
|
||||
type: 'ir.actions.act_window',
|
||||
res_model: action.res_model,
|
||||
views: [[formViewId, 'form']],
|
||||
res_id: recordId,
|
||||
});
|
||||
},
|
||||
createRecord: async () => {
|
||||
const [formViewId] = this.action.views.find((view) => {
|
||||
return view[1] === 'form';
|
||||
}) || [false];
|
||||
this.saveEmbedViewFilters();
|
||||
this.actionService.doAction({
|
||||
type: 'ir.actions.act_window',
|
||||
res_model: action.res_model,
|
||||
views: [[formViewId, 'form']],
|
||||
});
|
||||
},
|
||||
};
|
||||
if (action.search_view_id) {
|
||||
viewProps.searchViewId = action.search_view_id[0];
|
||||
}
|
||||
if (context.orderBy) {
|
||||
try {
|
||||
viewProps.orderBy = JSON.parse(context.orderBy);
|
||||
} catch {};
|
||||
}
|
||||
if (this.props.viewType in EMBEDDED_VIEW_LIMITS) {
|
||||
viewProps.limit = EMBEDDED_VIEW_LIMITS[this.props.viewType];
|
||||
}
|
||||
viewProps.irFilters = this._loadKnowledgeFavorites();
|
||||
viewProps.onSaveKnowledgeFavorite = this.onSaveKnowledgeFavorite.bind(this);
|
||||
viewProps.onDeleteKnowledgeFavorite = this.onDeleteKnowledgeFavorite.bind(this);
|
||||
|
||||
this.EmbeddedView = EmbeddedView;
|
||||
this.embedViewsFilterService.applyFilter(
|
||||
this.actionService.currentController,
|
||||
this.props.context.knowledgeEmbeddedViewId,
|
||||
viewProps
|
||||
);
|
||||
this.embeddedViewProps = viewProps;
|
||||
this.action = action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit an embedded view.
|
||||
* Each embedded view that needs "additionalProps" should implement their
|
||||
* own edition dialog.
|
||||
*/
|
||||
_onEditBtnClick() {
|
||||
if (this.embeddedViewProps.resModel === "knowledge.article" && this.embeddedViewProps.type === "calendar") {
|
||||
this.editItemCalendarProps();
|
||||
} else {
|
||||
throw new Error("Can not edit the view: The dialog is not implemented");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename an embedded view
|
||||
*/
|
||||
_onRenameBtnClick () {
|
||||
this.dialogService.add(PromptEmbeddedViewNameDialog, {
|
||||
isNew: false,
|
||||
defaultName: this.props.getTitle(),
|
||||
viewType: this.props.viewType,
|
||||
save: name => {
|
||||
this.props.setTitle(name);
|
||||
},
|
||||
close: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an embedded view (fullscreen)
|
||||
*/
|
||||
_onOpenBtnClick () {
|
||||
if (this.action.type !== "ir.actions.act_window") {
|
||||
throw new Error('Can not open the view: The action is not an "ir.actions.act_window"');
|
||||
}
|
||||
const props = this.state.additionalViewProps || {};
|
||||
if (this.action.context.orderBy) {
|
||||
try {
|
||||
props.orderBy = JSON.parse(this.action.context.orderBy);
|
||||
} catch {};
|
||||
}
|
||||
this.saveEmbedViewFilters();
|
||||
this.action.globalState = this.getEmbeddedViewGlobalState();
|
||||
this.actionService.doAction(this.action, {
|
||||
viewType: this.props.viewType,
|
||||
props,
|
||||
additionalContext: { knowledgeEmbeddedViewId: this.props.context.knowledgeEmbeddedViewId }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is called when opening a record from an embedded list or kanban view or when creating
|
||||
* a new record in the said view.
|
||||
* This function calls the correct function of the filter service in order to save the searchModel of the
|
||||
* view.
|
||||
* By saving the searchModel we allow filters applied to a view to be reassigned to it when coming back
|
||||
* via the breadcrumbs created by opening/creating a record.
|
||||
*/
|
||||
saveEmbedViewFilters() {
|
||||
this.embedViewsFilterService.saveFilters(
|
||||
this.actionService.currentController,
|
||||
this.props.context.knowledgeEmbeddedViewId,
|
||||
this.getEmbeddedViewGlobalState().searchModel
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load search favorites from the view arch.
|
||||
*/
|
||||
_loadKnowledgeFavorites() {
|
||||
const data = decodeDataBehaviorProps(this.props.anchor.getAttribute("data-behavior-props"));
|
||||
const favorites = data.favorites || [];
|
||||
|
||||
return favorites.map((favorite) => {
|
||||
favorite.isActive = favorite.isActive || false;
|
||||
favorite.context = JSON.stringify(favorite.context);
|
||||
return favorite;
|
||||
});
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
.o_knowledge_form_view {
|
||||
.o_knowledge_article_view_form_dynamic_width ~ .o_knowledge_editor .o_knowledge_behavior_type_embedded_view {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
.o_control_panel {
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_knowledge_behavior_type_embedded_view {
|
||||
margin-left: calc(-1 * var(--editor-gutter-size));
|
||||
margin-right: calc(-1 * var(--editor-gutter-size));
|
||||
position: relative;
|
||||
background-color: $o-webclient-background-color;
|
||||
border-left: 2px solid transparent; // adjust to resizer
|
||||
|
||||
.o_knowledge_toolbar {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
height: 2rem;
|
||||
top: -2rem;
|
||||
right: 0rem;
|
||||
|
||||
> div {
|
||||
height: 2rem;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
.o_knowledge_toolbar {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
.o_knowledge_embedded_view {
|
||||
.o_content:not(.o_view_sample_data) .o_view_nocontent:not(.o_knowledge_item_calendar_nocontent) {
|
||||
display: none !important;
|
||||
}
|
||||
.breadcrumb {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
.o_view_nocontent_empty_folder:before {
|
||||
@extend %o-nocontent-empty-document;
|
||||
}
|
||||
|
||||
// View adjustments:
|
||||
|
||||
.o_pivot_add_spreadsheet,
|
||||
.o_graph_insert_spreadsheet {
|
||||
display: none;
|
||||
}
|
||||
.o_list_renderer {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
.o_kanban_renderer {
|
||||
min-height: 6rem;
|
||||
}
|
||||
.o_kanban_view {
|
||||
.o_project_updates_breadcrumb {
|
||||
display: none !important;
|
||||
}
|
||||
.o_field_properties {
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
.o_content:has(div.o_kanban_group) .o_kanban_renderer {
|
||||
// force kanban grouped by (stage mainly) to be a bit taller,
|
||||
// to ease reading of stages.
|
||||
min-height: 250px;
|
||||
}
|
||||
}
|
||||
.o_graph_renderer {
|
||||
.o_graph_canvas_container {
|
||||
min-height: clamp(300px, 50vh, 800px);
|
||||
}
|
||||
}
|
||||
.o-map-renderer {
|
||||
.o-map-renderer--container,
|
||||
.o-map-renderer--pin-list-container {
|
||||
height: auto;
|
||||
}
|
||||
.o-map-renderer--container {
|
||||
min-height: clamp(300px, 50vh, 800px);
|
||||
}
|
||||
.leaflet-map-pane {
|
||||
@include o-position-absolute(0, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
.o_calendar_renderer {
|
||||
height: auto !important;
|
||||
max-height: clamp(300px, 60vh, 800px);
|
||||
.o_calendar_widget {
|
||||
height: auto !important;
|
||||
min-height: clamp(300px, 60vh, 800px);
|
||||
// Calendar Renderer's height calculation doesn't work properly on embedded Year views
|
||||
background-color: $o-view-background-color;
|
||||
}
|
||||
}
|
||||
.o_calendar_container {
|
||||
.o_calendar_sidebar_container {
|
||||
max-height: clamp(300px, 60vh, 800px);
|
||||
}
|
||||
}
|
||||
.o_calendar_filter .ui-menu-item a {
|
||||
color: $o-gray-700 !important;
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates>
|
||||
<t t-name="knowledge.EmbeddedViewManager">
|
||||
<t t-set="edit">Edit</t>
|
||||
<t t-set="rename">Rename</t>
|
||||
<t t-set="open">Open</t>
|
||||
<BehaviorToolbar buttonsGroupClass="'bg-white rounded-top border border-bottom-0'">
|
||||
<t t-set-slot="buttons">
|
||||
<BehaviorToolbarButton
|
||||
t-if="props.additionalViewProps"
|
||||
icon="'fa-pencil'"
|
||||
label="edit"
|
||||
onClick.bind="_onEditBtnClick"
|
||||
/>
|
||||
<BehaviorToolbarButton
|
||||
t-else=""
|
||||
hidden="props.readonly"
|
||||
icon="'fa-pencil'"
|
||||
label="rename"
|
||||
onClick.bind="_onRenameBtnClick"
|
||||
/>
|
||||
<BehaviorToolbarButton
|
||||
icon="'fa-external-link'"
|
||||
label="open"
|
||||
onClick.bind="_onOpenBtnClick"
|
||||
/>
|
||||
</t>
|
||||
</BehaviorToolbar>
|
||||
<div class="o_knowledge_embedded_view d-flex flex-column flex-grow-1 border-top border-bottom"
|
||||
data-oe-transient-content="true">
|
||||
<t t-component="EmbeddedView" t-props="allEmbeddedViewProps"/>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,385 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { downloadFile } from "@web/core/network/download";
|
||||
import { getDataURLFromFile, getOrigin } from "@web/core/utils/urls";
|
||||
import { FileModel } from "@web/core/file_viewer/file_model";
|
||||
import { useFileViewer } from "@web/core/file_viewer/file_viewer_hook";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { AlertDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
|
||||
import { renderToElement } from "@web/core/utils/render";
|
||||
|
||||
import {
|
||||
onMounted,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "@odoo/owl";
|
||||
|
||||
import { setSelection, rightPos } from "@web_editor/js/editor/odoo-editor/src/utils/utils";
|
||||
|
||||
import { AttachToMessageMacro, UseAsAttachmentMacro } from "@knowledge/macros/file_macros";
|
||||
import { AbstractBehavior } from "@knowledge/components/behaviors/abstract_behavior/abstract_behavior";
|
||||
import {
|
||||
BehaviorToolbar,
|
||||
BehaviorToolbarButton,
|
||||
} from "@knowledge/components/behaviors/behavior_toolbar/behavior_toolbar";
|
||||
import {
|
||||
copyOids,
|
||||
decodeDataBehaviorProps,
|
||||
encodeDataBehaviorProps,
|
||||
getPropNameNode,
|
||||
useRefWithSingleCollaborativeChild,
|
||||
} from "@knowledge/js/knowledge_utils";
|
||||
|
||||
export class FileBehavior extends AbstractBehavior {
|
||||
static components = {
|
||||
BehaviorToolbar,
|
||||
BehaviorToolbarButton,
|
||||
};
|
||||
static props = {
|
||||
...AbstractBehavior.props,
|
||||
// TODO ABD make it not optional after upgrade
|
||||
fileData: { type: Object, optional: true },
|
||||
|
||||
// TODO ABD remove obsolete props after upgrade
|
||||
fileExtension: { type: String, optional: true },
|
||||
fileImage: { type: Object, optional: true },
|
||||
fileName: { type: String, optional: true },
|
||||
};
|
||||
static template = "knowledge.FileBehavior";
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.actionService = useService('action');
|
||||
this.dialogService = useService('dialog');
|
||||
this.rpcService = useService('rpc');
|
||||
this.uiService = useService('ui');
|
||||
this.macrosServices = {
|
||||
action: this.actionService,
|
||||
dialog: this.dialogService,
|
||||
ui: this.uiService,
|
||||
};
|
||||
this.targetRecordInfo = this.knowledgeCommandsService.getCommandsRecordInfo();
|
||||
|
||||
// TODO ABD change `this.fileData` to `this.props.fileData` when
|
||||
// upgrade is properly implemented.
|
||||
this.state = useState({
|
||||
fileModel: Object.assign(new FileModel(), this.fileData),
|
||||
editFileName: false,
|
||||
});
|
||||
this.attachmentViewer = useFileViewer();
|
||||
this.fileNameRef = useRefWithSingleCollaborativeChild(
|
||||
'fileNameRef',
|
||||
(element) => {
|
||||
if (element) {
|
||||
this.setFileName(this.fileNameRef.el.textContent);
|
||||
} else {
|
||||
this.renderFileName();
|
||||
}
|
||||
}
|
||||
);
|
||||
if (!this.props.readonly) {
|
||||
this.nameInput = useRef('nameInput');
|
||||
useEffect(() => {
|
||||
if (this.state.editFileName) {
|
||||
this.nameInput.el.value = this.state.fileModel.filename;
|
||||
this.nameInput.el.focus();
|
||||
this.nameInput.el.select();
|
||||
}
|
||||
}, () => [this.state.editFileName]);
|
||||
useEffect(() => {
|
||||
if (this.state.fileModel.filename !== this.fileNameRef.el.textContent) {
|
||||
this.renderFileName();
|
||||
this.editor.historyStep();
|
||||
}
|
||||
}, () => [this.state.fileModel.filename]);
|
||||
} else {
|
||||
onMounted(() => {
|
||||
this.renderFileName();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Migration
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* While the way to upgrade knowledge articles has not been decided, the
|
||||
* upgrade method will be used to adjust Behavior props to the latest
|
||||
* requirements.
|
||||
*
|
||||
* This method is used to extract information from existing `/file`
|
||||
* elements that were saved in the database before the introduction of
|
||||
* the FileViewer usage, and convert them to the new fileData prop.
|
||||
* Notably, the attachment ID, the access token and the checksum are
|
||||
* extracted from the href, only if the url matches the current domain.
|
||||
* An example of a previously saved structure is the following:
|
||||
* <div class="o_knowledge_behavior_anchor o_knowledge_behavior_type_file">
|
||||
* <div data-prop-name="fileImage">
|
||||
* <a href="https://example.com/web/content/1?unique=aaa&access_token=bbb&download=true"
|
||||
* data-mimetype="image/jpeg" class="o_image"></a>
|
||||
* </div>
|
||||
* <div data-prop-name="fileName">name.jpg</div>
|
||||
* <div data-prop-name="fileExtension">jpg</div>
|
||||
* </div>
|
||||
*/
|
||||
upgradeKnowledgeBehavior() {
|
||||
if (this.props.fileData) {
|
||||
// FileBehavior is already updated to latest version, nothing to do
|
||||
this.fileData = this.props.fileData;
|
||||
return;
|
||||
}
|
||||
// `fileName` and `fileExtension` were html props under `data-prop-name`
|
||||
// and now are stored as text in behaviorProps. Ensure that their value
|
||||
// are not Markup objects.
|
||||
const fileName = String(this.props.fileName || '');
|
||||
const fileExtension = String(this.props.fileExtension || '');
|
||||
// Craft the file prop (which should be mandatory) if not present.
|
||||
const blueprint = this.props.anchor.cloneNode(false);
|
||||
blueprint.replaceChildren(...this.props.blueprintNodes);
|
||||
const htmlFileImageLink = getPropNameNode('fileImage', blueprint).querySelector('a');
|
||||
const href = htmlFileImageLink.getAttribute("href");
|
||||
const mimetype = htmlFileImageLink.dataset.mimetype;
|
||||
let accessToken, checksum, id, type, url;
|
||||
if (href.startsWith(getOrigin())) {
|
||||
id = parseInt((href.match(/\/web\/(?:content|image)\/(\d+)/) || [])[1]);
|
||||
checksum = (href.match(/unique=([^&]+)/) || [])[1];
|
||||
accessToken = (href.match(/access_token=([^&]+)/) || [])[1];
|
||||
}
|
||||
if (!id) {
|
||||
type = "url";
|
||||
url = href.replace(/\?.*$/, "");
|
||||
} else {
|
||||
type = "binary";
|
||||
}
|
||||
const defaultName = _t("untitled");
|
||||
this.fileData = {
|
||||
accessToken,
|
||||
checksum,
|
||||
extension: fileExtension,
|
||||
filename: fileName || defaultName,
|
||||
id,
|
||||
mimetype,
|
||||
name: fileName || defaultName,
|
||||
type,
|
||||
url,
|
||||
};
|
||||
// Overwrite existing behavior props with the only final prop: fileData.
|
||||
this.props.anchor.dataset.behaviorProps = encodeDataBehaviorProps({
|
||||
fileData: this.fileData,
|
||||
});
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// TECHNICAL
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
* Render the fileName just before the Behavior is inserted in the editor.
|
||||
* @see AbstractBehavior for the full explanation.
|
||||
*/
|
||||
extraRender() {
|
||||
super.extraRender();
|
||||
this.renderFileName();
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
* Ensure that behavior props stored in the data-behavior-props attribute
|
||||
* of the anchor of this Behavior are up to date with the latest
|
||||
* implementation. @see AbstractBehavior for the full explanation.
|
||||
*/
|
||||
setupAnchor() {
|
||||
super.setupAnchor();
|
||||
// TODO ABD remove when the upgrade procedure has been decided.
|
||||
this.upgradeKnowledgeBehavior();
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
* The fileNameRef nodes have to be shared in collaboration but are not
|
||||
* directly an html prop of this Behavior, hence this override to set their
|
||||
* oids. @see AbstractBehavior for the full explanation.
|
||||
*/
|
||||
synchronizeOids(blueprint) {
|
||||
super.synchronizeOids(blueprint);
|
||||
const currentFileNameEl = this.props.anchor.querySelector('.o_knowledge_file_name[data-oe-protected="false"]');
|
||||
const blueprintFileNameEl = blueprint.querySelector('.o_knowledge_file_name[data-oe-protected="false"]');
|
||||
if (!blueprintFileNameEl) {
|
||||
return;
|
||||
}
|
||||
copyOids(blueprintFileNameEl, currentFileNameEl);
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// BUSINESS
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
renameFile() {
|
||||
let newName = this.nameInput.el.value;
|
||||
if (newName.length) {
|
||||
this.setFileName(newName);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
renderFileName() {
|
||||
const fileNameEl = renderToElement("knowledge.FileBehaviorFileName", {
|
||||
fileName: this.state.fileModel.filename,
|
||||
});
|
||||
this.fileNameRef.el.replaceChildren(fileNameEl);
|
||||
}
|
||||
|
||||
setFileName(newName) {
|
||||
if (newName === this.state.fileModel.filename) {
|
||||
return;
|
||||
}
|
||||
// filename is the name of the file as written in the editor by the
|
||||
// user. It does not necessarily have the file extension.
|
||||
this.state.fileModel.filename = newName;
|
||||
if (this.state.fileModel.extension) {
|
||||
const pattern = new RegExp(`\\.${this.state.fileModel.extension}$`, 'i');
|
||||
if (!newName.match(pattern)) {
|
||||
newName += `.${this.state.fileModel.extension}`;
|
||||
}
|
||||
}
|
||||
// name is the full name of the file (always with extension)
|
||||
// and is used as the url queryParam when downloading it.
|
||||
this.state.fileModel.name = newName;
|
||||
const props = decodeDataBehaviorProps(this.props.anchor.dataset.behaviorProps);
|
||||
props.fileData = this.state.fileModel;
|
||||
this.props.anchor.dataset.behaviorProps = encodeDataBehaviorProps(props);
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// HANDLERS
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
onBlurNameInput(ev) {
|
||||
this.renameFile();
|
||||
this.state.editFileName = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback function called when the user clicks on the "Send as Message" button.
|
||||
* The function will execute a macro that will open the last opened form view,
|
||||
* compose a new message and attach the associated file to it.
|
||||
* @param {Event} ev
|
||||
*/
|
||||
async onClickAttachToMessage(ev) {
|
||||
const dataTransfer = new DataTransfer();
|
||||
try {
|
||||
const response = await window.fetch(this.state.fileModel.urlRoute);
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], this.state.fileModel.name, {
|
||||
type: blob.type
|
||||
});
|
||||
/**
|
||||
* dataTransfer will be used to mimic a drag and drop of
|
||||
* the file in the target record chatter.
|
||||
* @see KnowledgeMacro
|
||||
*/
|
||||
dataTransfer.items.add(file);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const macro = new AttachToMessageMacro({
|
||||
targetXmlDoc: this.targetRecordInfo.xmlDoc,
|
||||
breadcrumbs: this.targetRecordInfo.breadcrumbs,
|
||||
data: {
|
||||
dataTransfer: dataTransfer,
|
||||
},
|
||||
services: this.macrosServices,
|
||||
});
|
||||
macro.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback function called when the user clicks on the "Download" button.
|
||||
* The function will simply open a link that will trigger the download of
|
||||
* the associated file. If the url is not valid, the function will display
|
||||
* an error message.
|
||||
* @param {Event} ev
|
||||
*/
|
||||
async onClickDownload(ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
try {
|
||||
await downloadFile(this.state.fileModel.downloadUrl);
|
||||
} catch {
|
||||
this.dialogService.add(AlertDialog, {
|
||||
body: _t(
|
||||
'Oops, the file %s could not be found. Please replace this file box by a new one to re-upload the file.',
|
||||
this.state.fileModel.name
|
||||
),
|
||||
title: _t('Missing File'),
|
||||
confirm: () => {},
|
||||
confirmLabel: _t('Close'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onClickFileImage(ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (this.state.fileModel.isViewable) {
|
||||
this.attachmentViewer.open(this.state.fileModel);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback function called when the user clicks on the "Use As Attachment" button.
|
||||
* The function will execute a macro that will open the last opened form view
|
||||
* and add the associated file to the attachments of the chatter.
|
||||
* @param {Event} ev
|
||||
*/
|
||||
async onClickUseAsAttachment(ev) {
|
||||
let attachment;
|
||||
try {
|
||||
const response = await window.fetch(this.state.fileModel.urlRoute);
|
||||
const blob = await response.blob();
|
||||
const dataURL = await getDataURLFromFile(blob);
|
||||
attachment = await this.rpcService('/web_editor/attachment/add_data', {
|
||||
name: this.state.fileModel.name,
|
||||
data: dataURL.split(',')[1],
|
||||
is_image: false,
|
||||
res_id: this.targetRecordInfo.resId,
|
||||
res_model: this.targetRecordInfo.resModel,
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!attachment) {
|
||||
return;
|
||||
}
|
||||
const macro = new UseAsAttachmentMacro({
|
||||
targetXmlDoc: this.targetRecordInfo.xmlDoc,
|
||||
breadcrumbs: this.targetRecordInfo.breadcrumbs,
|
||||
data: null,
|
||||
services: this.macrosServices,
|
||||
});
|
||||
macro.start();
|
||||
}
|
||||
|
||||
onFocusFileName(ev) {
|
||||
this.state.editFileName = true;
|
||||
}
|
||||
|
||||
onKeydownNameInput(ev) {
|
||||
ev.stopPropagation();
|
||||
if (ev.key !== "Enter") {
|
||||
return;
|
||||
} else {
|
||||
ev.preventDefault();
|
||||
}
|
||||
if (this.renameFile()) {
|
||||
this.state.editFileName = false;
|
||||
const afterFilePos = rightPos(this.props.anchor);
|
||||
setSelection(...afterFilePos, ...afterFilePos, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<!-- /file template -->
|
||||
<t t-name="knowledge.FileBehavior">
|
||||
<t t-set="send_as_message">Send as Message</t>
|
||||
<t t-set="use_as_attachment">Use as Attachment</t>
|
||||
<t t-set="download">Download</t>
|
||||
<div class="d-flex justify-content-between flex-wrap w-100">
|
||||
<div class="d-flex flex-grow-1">
|
||||
<div class="o_knowledge_file_image d-flex align-items-center">
|
||||
<a class="o_image" t-att-title="state.fileModel.filename" t-att-data-mimetype="state.fileModel.mimetype"
|
||||
t-att-href="state.fileModel.downloadUrl" t-on-click="onClickFileImage"
|
||||
/>
|
||||
</div>
|
||||
<div class="d-block ps-2 text-break flex-grow-1">
|
||||
<div t-attf-class="o_knowledge_file_name #{ state.editFileName ? 'd-none' : '' }"
|
||||
data-oe-protected="false" t-ref="fileNameRef" t-att-tabindex="!this.props.readonly ? '-1' : undefined"
|
||||
t-on-focus="props.readonly ? () => {} : onFocusFileName"/>
|
||||
<div data-oe-transient-content="true">
|
||||
<input t-if="!props.readonly" t-attf-class="rounded border-0 p-0 #{ state.editFileName ? '' : 'd-none'}"
|
||||
t-ref="nameInput" type="text" maxLength="100" t-att-placeholder="state.fileModel.filename"
|
||||
t-on-blur="onBlurNameInput" t-on-input.stop="" t-on-keydown="onKeydownNameInput"/>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap">
|
||||
<div t-if="state.fileModel.extension" class="o_knowledge_file_extension text-uppercase small"
|
||||
t-out="state.fileModel.extension"/>
|
||||
<div class="flex-grow-1 d-flex justify-content-end">
|
||||
<t t-set="sendAsMessageLabel">Send as Message</t>
|
||||
<t t-set="useAsAttachmentLabel">Use as Attachment</t>
|
||||
<t t-set="downloadLabel">Download</t>
|
||||
<BehaviorToolbar>
|
||||
<t t-set-slot="buttons">
|
||||
<BehaviorToolbarButton
|
||||
hidden="!targetRecordInfo?.canPostMessages"
|
||||
icon="'fa-envelope'"
|
||||
label="sendAsMessageLabel"
|
||||
onClick.bind="onClickAttachToMessage"
|
||||
/>
|
||||
<BehaviorToolbarButton
|
||||
hidden="!targetRecordInfo?.canAttachFiles"
|
||||
icon="'fa-paperclip'"
|
||||
label="useAsAttachmentLabel"
|
||||
onClick.bind="onClickUseAsAttachment"
|
||||
/>
|
||||
<BehaviorToolbarButton
|
||||
icon="'fa-download'"
|
||||
label="downloadLabel"
|
||||
onClick.bind="onClickDownload"
|
||||
/>
|
||||
</t>
|
||||
</BehaviorToolbar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-name="knowledge.FileBehaviorFileName">
|
||||
<div class="d-flex">
|
||||
<span t-out="fileName"/>
|
||||
<i class="fa fa-pencil mx-1 invisible align-items-center d-inline-flex"/>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { AbstractBehavior } from "@knowledge/components/behaviors/abstract_behavior/abstract_behavior";
|
||||
import { HEADINGS, fetchValidHeadings } from "@knowledge/js/tools/knowledge_tools";
|
||||
import {
|
||||
onWillStart,
|
||||
useEffect,
|
||||
useState } from "@odoo/owl";
|
||||
|
||||
export class TableOfContentBehavior extends AbstractBehavior {
|
||||
static template = "knowledge.TableOfContentBehavior";
|
||||
|
||||
setup () {
|
||||
super.setup();
|
||||
this.state = useState({
|
||||
toc: []
|
||||
});
|
||||
if (!this.props.readonly) {
|
||||
useEffect(() => {
|
||||
/**
|
||||
* When the user drags a link from the table of content and drop
|
||||
* it on the table of content, the editor was duplicating the link.
|
||||
* To prevent that behavior, we will stop the propagation of the
|
||||
* drop event.
|
||||
* @param {Event} event
|
||||
*/
|
||||
const onDrop = event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
const observer = this.setMutationObserver();
|
||||
this.props.anchor.addEventListener('drop', onDrop);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
this.props.anchor.removeEventListener('drop', onDrop);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
onWillStart(() => {
|
||||
this._updateTableOfContents();
|
||||
});
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// TECHNICAL
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Observes the changes made to the titles of the editor.
|
||||
* @returns {MutationObserver}
|
||||
*/
|
||||
setMutationObserver () {
|
||||
const observer = new MutationObserver(mutationList => {
|
||||
const update = mutationList.find(mutation => {
|
||||
if (Array.from(mutation.addedNodes).find(node => HEADINGS.includes(node.tagName)) ||
|
||||
Array.from(mutation.removedNodes).find(node => HEADINGS.includes(node.tagName))) {
|
||||
// We just added/removed a header node -> update the ToC
|
||||
return true;
|
||||
}
|
||||
|
||||
// Powerbox is open -> do not attempt to update the ToC
|
||||
if (this.editor.powerbox.isOpen) {
|
||||
if (this.updateTimeout) {
|
||||
window.clearTimeout(this.updateTimeout);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// check if we modified the content of a header element
|
||||
const target = mutation.target;
|
||||
const headerNode = this._findClosestHeader(target);
|
||||
|
||||
return headerNode && headerNode.parentElement === this.props.root;
|
||||
});
|
||||
if (update) {
|
||||
this.delayedUpdateTableOfContents();
|
||||
}
|
||||
});
|
||||
observer.observe(this.props.root, {
|
||||
childList: true,
|
||||
attributes: false,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
return observer;
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// BUSINESS
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Allows to debounce the update of the Table of Content to avoid updating whenever every single
|
||||
* character is typed. The debounce is set to 500ms.
|
||||
*/
|
||||
delayedUpdateTableOfContents() {
|
||||
if (this.updateTimeout) {
|
||||
window.clearTimeout(this.updateTimeout);
|
||||
}
|
||||
this.updateTimeout = window.setTimeout(this._updateTableOfContents.bind(this), 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper methods that fetches the closest Header Element based on a target Node.
|
||||
*
|
||||
* @param {Node} node
|
||||
* @returns {Element} the closest header or undefined if not found
|
||||
*/
|
||||
_findClosestHeader(node) {
|
||||
if (node && node.nodeType === Node.TEXT_NODE) {
|
||||
// we are modifying the text of a Node, check its parent
|
||||
node = node.parentElement;
|
||||
}
|
||||
|
||||
if (node && node.nodeType === Node.ELEMENT_NODE) {
|
||||
if (!HEADINGS.includes(node.tagName)) {
|
||||
// this node is not a direct header node, but it could be *inside* a header node
|
||||
// -> check closest header node
|
||||
node = node.closest(HEADINGS.join(','));
|
||||
}
|
||||
|
||||
if (node && HEADINGS.includes(node.tagName)) {
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the Table of Content to match the document headings.
|
||||
* We pause our observer during the process.
|
||||
*
|
||||
* We have a 'depth' system for headers, the depth of a header is simply how much left-padding
|
||||
* it is showing to give an impression of hierarchy, e.g:
|
||||
* - Header 1
|
||||
* - Sub-Header 1
|
||||
* - Sub-sub-header 1
|
||||
* - Sub-Header 2
|
||||
* - Header 2
|
||||
* - Sub-Header 3
|
||||
*
|
||||
* The logic is as follows:
|
||||
* - If it is the same tag as 'the previous one' in the loop
|
||||
* -> keep the same depth
|
||||
* - If the header tag is "bigger" than the previous one (a H5 compared to H4)
|
||||
* -> Increase the depth by one
|
||||
* /!\ We only increase by one, even if we are comparing a H5 to a H3
|
||||
* This avoids some strange spacing and lets the user choose its headers style
|
||||
* - If the header tag is "smaller" than the previous one
|
||||
* -> When going down, check if our current "tree" (our hierarchy starting with the highest
|
||||
* tag) already has this type of tag at a certain depth, and use that.
|
||||
* Otherwise use the depth of the tag (0 for h1, 1 for h2, 2 for h3, ...).
|
||||
*
|
||||
* Some examples of non-trivial header hierarchy can be found in the QUnit tests of this method.
|
||||
*/
|
||||
_updateTableOfContents () {
|
||||
|
||||
let currentDepthByTag = {};
|
||||
let previousTag = undefined;
|
||||
let previousDepth = -1;
|
||||
let index = 0;
|
||||
|
||||
this.state.toc = fetchValidHeadings(this.props.root).map(heading => {
|
||||
let depth = HEADINGS.indexOf(heading.tagName);
|
||||
if (depth !== previousDepth && heading.tagName === previousTag) {
|
||||
depth = previousDepth;
|
||||
} else if (depth > previousDepth) {
|
||||
if (heading.tagName !== previousTag && HEADINGS.indexOf(previousTag) < depth) {
|
||||
depth = previousDepth + 1;
|
||||
} else {
|
||||
depth = previousDepth;
|
||||
}
|
||||
} else if (depth < previousDepth) {
|
||||
if (currentDepthByTag.hasOwnProperty(heading.tagName)) {
|
||||
depth = currentDepthByTag[heading.tagName];
|
||||
}
|
||||
}
|
||||
|
||||
previousTag = heading.tagName;
|
||||
previousDepth = depth;
|
||||
|
||||
// going back to 0 depth, wipe-out the 'currentDepthByTag'
|
||||
if (depth === 0) {
|
||||
currentDepthByTag = {};
|
||||
}
|
||||
currentDepthByTag[heading.tagName] = depth;
|
||||
|
||||
return {
|
||||
depth: depth,
|
||||
index: index++,
|
||||
name: heading.innerText,
|
||||
tagName: heading.tagName,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// HANDLERS
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Scroll through the view to display the matching heading.
|
||||
* Adds a small highlight in/out animation using a class.
|
||||
*
|
||||
* @param {Event} event
|
||||
*/
|
||||
_onTocLinkClick (event) {
|
||||
event.preventDefault();
|
||||
const headingIndex = parseInt(event.target.getAttribute('data-oe-nodeid'));
|
||||
const targetHeading = fetchValidHeadings(this.props.root)[headingIndex];
|
||||
if (targetHeading){
|
||||
targetHeading.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
});
|
||||
targetHeading.classList.add('o_knowledge_header_highlight');
|
||||
window.setTimeout(() => {
|
||||
targetHeading.classList.remove('o_knowledge_header_highlight');
|
||||
}, 2000);
|
||||
} else {
|
||||
this._updateTableOfContents();
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<!-- /toc template -->
|
||||
<t t-name="knowledge.TableOfContentBehavior">
|
||||
<i t-if="this.state.toc.length === 0">
|
||||
Add headings in this Article to create a Table of Content
|
||||
</i>
|
||||
<div t-else="" class="o_knowledge_toc_content">
|
||||
<t t-foreach="this.state.toc" t-as="heading" t-key="heading_index">
|
||||
<!-- Use 'data-oe-nodeid' as we need something that is not removed during sanitizing -->
|
||||
<a href="#" contenteditable="false"
|
||||
t-out="heading.name" t-att-data-oe-nodeid="heading.index" t-on-click="_onTocLinkClick"
|
||||
t-attf-class="oe_unremovable o_no_link_popover d-block o_knowledge_toc_link #{'o_knowledge_toc_link_depth_' + heading.depth}"/>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,153 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { AbstractBehavior } from "@knowledge/components/behaviors/abstract_behavior/abstract_behavior";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { SendAsMessageMacro, UseAsDescriptionMacro } from "@knowledge/macros/template_macros";
|
||||
import { Tooltip } from "@web/core/tooltip/tooltip";
|
||||
import { usePopover } from "@web/core/popover/popover_hook";
|
||||
import { useChildRef, useService } from "@web/core/utils/hooks";
|
||||
import { setCursorStart } from "@web_editor/js/editor/odoo-editor/src/utils/utils";
|
||||
import {
|
||||
useRef,
|
||||
markup,
|
||||
} from "@odoo/owl";
|
||||
import {
|
||||
BehaviorToolbar,
|
||||
BehaviorToolbarButton,
|
||||
} from "@knowledge/components/behaviors/behavior_toolbar/behavior_toolbar";
|
||||
import {
|
||||
getPropNameNode,
|
||||
} from "@knowledge/js/knowledge_utils";
|
||||
|
||||
|
||||
export class TemplateBehavior extends AbstractBehavior {
|
||||
static components = {
|
||||
BehaviorToolbar,
|
||||
BehaviorToolbarButton,
|
||||
};
|
||||
static props = {
|
||||
...AbstractBehavior.props,
|
||||
content: { type: Object, optional: true },
|
||||
};
|
||||
static template = "knowledge.TemplateBehavior";
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.actionService = useService("action");
|
||||
this.dialogService = useService("dialog");
|
||||
this.popover = usePopover(Tooltip);
|
||||
this.uiService = useService("ui");
|
||||
this.macrosServices = {
|
||||
action: this.actionService,
|
||||
dialog: this.dialogService,
|
||||
ui: this.uiService,
|
||||
};
|
||||
this.templateContent = useRef("templateContent");
|
||||
this.copyToClipboardButton = useChildRef();
|
||||
// <p><br/></p> can't be put in the template because adding a
|
||||
// t-if t-else in the template will add empty text nodes from
|
||||
// OWL in the editor, which are not compatible with collaborative:
|
||||
// if the template value is the props content, we get
|
||||
// text - value - text - text
|
||||
// and if the props content is undefined (else), we get
|
||||
// text - text - value - text
|
||||
// which means that for the same [value] nodes, we get different
|
||||
// html contents, which means they can't be synchronized.
|
||||
this.content = this.props.content || markup('<p><br/></p>');
|
||||
this.targetRecordInfo = this.knowledgeCommandsService.getCommandsRecordInfo();
|
||||
this.htmlFieldTargetMessage = _t('Use as %s', this.targetRecordInfo?.fieldInfo?.string || 'Description');
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// TECHNICAL
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a dataTransfer object with the editable content of the template
|
||||
* block, to be used for a paste event in the editor
|
||||
*/
|
||||
_createHtmlDataTransfer() {
|
||||
const dataTransfer = new DataTransfer();
|
||||
const content = this.props.anchor.querySelector('.o_knowledge_content');
|
||||
dataTransfer.setData('text/odoo-editor', `<p></p>${content.innerHTML}<p></p>`);
|
||||
return dataTransfer;
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// BUSINESS
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Set the cursor of the user inside the template block when the user types
|
||||
* the `/clipboard` command (Used for a new TemplateBehavior on its first
|
||||
* mount).
|
||||
*/
|
||||
setCursor() {
|
||||
setCursorStart(getPropNameNode("content", this.props.anchor).querySelector("p"));
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// HANDLERS
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
async onClickCopyToClipboard() {
|
||||
if (!this.templateContent.el) {
|
||||
return;
|
||||
}
|
||||
const blob = [new ClipboardItem({
|
||||
"text/html": new Blob([this.templateContent.el.innerHTML], { type: "text/html" }),
|
||||
"text/plain": new Blob([this.templateContent.el.innerHTML], { type: "text/plain" }),
|
||||
})];
|
||||
try {
|
||||
await browser.navigator.clipboard.write(blob);
|
||||
this.popover.open(this.copyToClipboardButton.el, {
|
||||
tooltip: _t("Content copied to clipboard."),
|
||||
});
|
||||
browser.setTimeout(this.popover.close, 800);
|
||||
} catch (error) {
|
||||
browser.console.warn(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback function called when the user clicks on the "Send as Message" button.
|
||||
* The function executes a macro that opens the latest form view, composes a
|
||||
* new message and attaches the associated file to it.
|
||||
* @param {Event} ev
|
||||
*/
|
||||
onClickSendAsMessage(ev) {
|
||||
const dataTransfer = this._createHtmlDataTransfer();
|
||||
const macro = new SendAsMessageMacro({
|
||||
targetXmlDoc: this.targetRecordInfo.xmlDoc,
|
||||
breadcrumbs: this.targetRecordInfo.breadcrumbs,
|
||||
data: {
|
||||
dataTransfer: dataTransfer,
|
||||
},
|
||||
services: this.macrosServices,
|
||||
});
|
||||
macro.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback function called when the user clicks on the "Use As ..." button.
|
||||
* The function executes a macro that opens the latest form view containing
|
||||
* a valid target field (see `KNOWLEDGE_RECORDED_FIELD_NAMES`) and copy/past
|
||||
* the content of the template to it.
|
||||
* @param {Event} ev
|
||||
*/
|
||||
onClickUseAsDescription(ev) {
|
||||
const dataTransfer = this._createHtmlDataTransfer();
|
||||
const macro = new UseAsDescriptionMacro({
|
||||
targetXmlDoc: this.targetRecordInfo.xmlDoc,
|
||||
breadcrumbs: this.targetRecordInfo.breadcrumbs,
|
||||
data: {
|
||||
fieldName: this.targetRecordInfo.fieldInfo.name,
|
||||
pageName: this.targetRecordInfo.fieldInfo.pageName,
|
||||
dataTransfer: dataTransfer,
|
||||
},
|
||||
services: this.macrosServices,
|
||||
});
|
||||
macro.start();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<!-- /clipboard template -->
|
||||
<t t-name="knowledge.TemplateBehavior">
|
||||
<t t-set="send_as_message">Send as Message</t>
|
||||
<t t-set="copy">Copy</t>
|
||||
<t t-set="copy_to_clipboard">Copy to Clipboard</t>
|
||||
<div class="d-flex justify-content-between flex-wrap w-100">
|
||||
<div class="o_knowledge_template_label px-2 py-1 text-break">Clipboard</div>
|
||||
<BehaviorToolbar>
|
||||
<t t-set-slot="buttons">
|
||||
<BehaviorToolbarButton
|
||||
hidden="!targetRecordInfo?.canPostMessages"
|
||||
icon="'fa-envelope'"
|
||||
label="send_as_message"
|
||||
onClick.bind="onClickSendAsMessage"
|
||||
/>
|
||||
<BehaviorToolbarButton
|
||||
hidden="!targetRecordInfo?.withHtmlField"
|
||||
icon="'fa-pencil-square'"
|
||||
label="htmlFieldTargetMessage"
|
||||
onClick.bind="onClickUseAsDescription"
|
||||
/>
|
||||
<BehaviorToolbarButton
|
||||
buttonRef="copyToClipboardButton"
|
||||
icon="'fa-clipboard'"
|
||||
label="copy"
|
||||
name="'copyToClipboard'"
|
||||
onClick.bind="onClickCopyToClipboard"
|
||||
title="copy_to_clipboard"
|
||||
/>
|
||||
</t>
|
||||
</BehaviorToolbar>
|
||||
</div>
|
||||
<div class="o_knowledge_content" data-prop-name="content" t-ref="templateContent" data-oe-protected="false"
|
||||
t-att-contenteditable="!props.readonly ? 'true' : 'false'">
|
||||
<t t-out="content"/>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,39 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { AbstractBehavior } from "@knowledge/components/behaviors/abstract_behavior/abstract_behavior";
|
||||
import { Component } from "@odoo/owl";
|
||||
import { getVideoUrl } from "@knowledge/js/knowledge_utils";
|
||||
|
||||
|
||||
class KnowledgeVideoIframe extends Component {
|
||||
static template = "knowledge.VideoIframe";
|
||||
static props = {
|
||||
src: { type: String },
|
||||
};
|
||||
}
|
||||
|
||||
export class VideoBehavior extends AbstractBehavior {
|
||||
static template = "knowledge.VideoBehavior";
|
||||
static props = {
|
||||
...AbstractBehavior.props,
|
||||
platform: { type: String },
|
||||
videoId: { type: String },
|
||||
params: { type: Object, optional: true },
|
||||
};
|
||||
static components = {
|
||||
VideoIframe: KnowledgeVideoIframe,
|
||||
};
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
setup () {
|
||||
super.setup();
|
||||
const url = getVideoUrl(
|
||||
this.props.platform,
|
||||
this.props.videoId,
|
||||
this.props.params
|
||||
);
|
||||
this.src = url.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<!-- /video template -->
|
||||
<t t-name="knowledge.VideoBehavior">
|
||||
<VideoIframe src="this.src"/>
|
||||
</t>
|
||||
<t t-name="knowledge.VideoIframe">
|
||||
<iframe t-att-src="props.src" title="Video player" frameborder="0" allowfullscreen="allowfullscreen"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share">
|
||||
Your browser does not support iframe.
|
||||
</iframe>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,71 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { AbstractBehavior } from "@knowledge/components/behaviors/abstract_behavior/abstract_behavior";
|
||||
import { makeContext } from "@web/core/context";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { useEffect } from "@odoo/owl";
|
||||
|
||||
|
||||
/**
|
||||
* Clickable "link" to access a view from an article with custom facets (only
|
||||
* usable in Odoo)
|
||||
*/
|
||||
export class ViewLinkBehavior extends AbstractBehavior {
|
||||
static props = {
|
||||
...AbstractBehavior.props,
|
||||
action_xml_id: { type: String, optional: true },
|
||||
act_window: { type: Object, optional: true },
|
||||
context: { type: Object },
|
||||
name: { type: String },
|
||||
view_type: { type: String }
|
||||
};
|
||||
static template = "knowledge.ViewLinkBehavior";
|
||||
|
||||
setup () {
|
||||
super.setup();
|
||||
this.actionService = useService('action');
|
||||
useEffect(() => {
|
||||
const type = this.props.readonly ? 'click' : 'dblclick';
|
||||
/**
|
||||
* @param {Event} event
|
||||
*/
|
||||
const onLinkClick = event => {
|
||||
this.openViewLink(event);
|
||||
};
|
||||
this.props.anchor.addEventListener(type, onLinkClick);
|
||||
return () => {
|
||||
this.props.anchor.removeEventListener(type, onLinkClick);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// HANDLERS
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {Event} event
|
||||
*/
|
||||
async openViewLink (event) {
|
||||
const action = await this.actionService.loadAction(
|
||||
this.props.act_window || this.props.action_xml_id,
|
||||
makeContext([this.props.context])
|
||||
);
|
||||
if (action.type !== "ir.actions.act_window") {
|
||||
throw new Error('Can not open the view: The action is not an "ir.actions.act_window"');
|
||||
}
|
||||
action.globalState = {
|
||||
searchModel: this.props.context.knowledge_search_model_state
|
||||
};
|
||||
const props = {};
|
||||
if (action.context.orderBy) {
|
||||
try {
|
||||
props.orderBy = JSON.parse(action.context.orderBy);
|
||||
} catch {};
|
||||
}
|
||||
this.actionService.doAction(action, {
|
||||
viewType: this.props.view_type,
|
||||
props
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
.o_knowledge_editor {
|
||||
.o_field_html.o_readonly_modifier {
|
||||
.o_knowledge_view_link {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<t t-name="knowledge.ViewLinkBehavior">
|
||||
<t t-esc="props.name"/>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,56 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registry } from '@web/core/registry';
|
||||
import { standardWidgetProps } from '@web/views/widgets/standard_widget_props';
|
||||
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { Chatter } from '@mail/core/web/chatter';
|
||||
import { SIZES } from "@web/core/ui/ui_service";
|
||||
|
||||
|
||||
import { Component, useRef, useState } from '@odoo/owl';
|
||||
|
||||
export class KnowledgeArticleChatter extends Component {
|
||||
static template = 'knowledge.KnowledgeArticleChatter';
|
||||
static components = { Chatter };
|
||||
static props = { ...standardWidgetProps };
|
||||
|
||||
setup() {
|
||||
this.state = useState({
|
||||
displayChatter: false,
|
||||
});
|
||||
|
||||
this.root = useRef('root')
|
||||
this.ui = useService("ui");
|
||||
|
||||
this.env.bus.addEventListener('KNOWLEDGE:TOGGLE_CHATTER', this.toggleChatter.bind(this));
|
||||
}
|
||||
|
||||
toggleChatter(event) {
|
||||
this.state.displayChatter = event.detail.displayChatter;
|
||||
if (this.state.displayChatter) {
|
||||
this.root.el?.parentElement?.classList.remove('d-none');
|
||||
} else {
|
||||
this.root.el?.parentElement?.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
get isChatterAside() {
|
||||
return this.ui.size >= SIZES.LG;
|
||||
}
|
||||
}
|
||||
|
||||
export const knowledgeChatterPanel = {
|
||||
component: KnowledgeArticleChatter,
|
||||
additionalClasses: [
|
||||
'o_knowledge_chatter',
|
||||
'col-12',
|
||||
'col-lg-4',
|
||||
'position-relative',
|
||||
'd-none',
|
||||
'p-0',
|
||||
'test'
|
||||
]
|
||||
};
|
||||
|
||||
registry.category('view_widgets').add('knowledge_chatter_panel', knowledgeChatterPanel);
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<div t-name="knowledge.KnowledgeArticleChatter" t-ref="root">
|
||||
<div t-if="this.state.displayChatter" class="o_FormRenderer_chatterContainer oe_chatter o_scroll_view_lg">
|
||||
<Chatter
|
||||
threadModel="'knowledge.article'"
|
||||
threadId="this.props.record.resId"
|
||||
webRecord="this.props.record"
|
||||
hasParentReloadOnAttachmentsChanged="true"
|
||||
hasParentReloadOnFollowersUpdate="true"
|
||||
hasParentReloadOnMessagePosted="true"
|
||||
isChatterAside="isChatterAside"
|
||||
saveRecord="props.record.save.bind(props.record, { reload: false })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</templates>
|
||||
@@ -0,0 +1,559 @@
|
||||
/** @odoo-module */
|
||||
import { useBus, useService } from '@web/core/utils/hooks';
|
||||
import { usePopover } from '@web/core/popover/popover_hook';
|
||||
import { Composer } from '@mail/core/common/composer';
|
||||
import { Thread } from '@mail/core/common/thread';
|
||||
import { KnowledgeCommentsPopover } from '../comments_popover/comments_popover';
|
||||
import { KnowledgeCommentCreatorComposer } from '../composer/composer';
|
||||
import { closestBlock } from '@web_editor/js/editor/odoo-editor/src/utils/utils';
|
||||
import { _t } from '@web/core/l10n/translation';
|
||||
|
||||
import {
|
||||
Component,
|
||||
onMounted,
|
||||
onPatched,
|
||||
onWillStart,
|
||||
onWillUpdateProps,
|
||||
useChildSubEnv,
|
||||
useExternalListener,
|
||||
useRef,
|
||||
useEffect,
|
||||
useState
|
||||
} from '@odoo/owl';
|
||||
|
||||
export class KnowledgeCommentsThread extends Component {
|
||||
|
||||
static components = { Composer, Thread, KnowledgeCommentCreatorComposer };
|
||||
static props = {
|
||||
articleId: {type: Number, optional: true},
|
||||
anchors: {type: Array, optional: true},
|
||||
changeCommentResolvedState: Function,
|
||||
destroyComment: Function,
|
||||
forceFullSize: {type: Boolean, optional: true},
|
||||
insertNewThread: {type: Function, optional: true},
|
||||
isCreationMode: {type: Boolean, optional: true},
|
||||
isResolved: Boolean,
|
||||
knowledgeThreadId: {type: Number, optional: true},
|
||||
/**@see \@web/model/relational_model/record */
|
||||
record: Object,
|
||||
/**@see \@mail/core/common/thread_model*/
|
||||
thread: {type: Object, optional: true},
|
||||
top: {type: Number, optional: true},
|
||||
writeDate: {optional: true}
|
||||
};
|
||||
static template = 'knowledge.KnowledgeCommentsThread';
|
||||
|
||||
setup() {
|
||||
this.targetRef = useRef('targetRef');
|
||||
|
||||
this.threadService = useService('mail.thread');
|
||||
this.messageService = useService('mail.message');
|
||||
this.userService = useService('user');
|
||||
this.orm = useService('orm');
|
||||
this.uiService = useService('ui');
|
||||
this.popover = usePopover(KnowledgeCommentsPopover, {
|
||||
closeOnClickAway: false,
|
||||
position: 'bottom-start',
|
||||
popoverClass: 'overflow-auto mh-50 o_knowledge_comments_popover'
|
||||
});
|
||||
|
||||
this.messages = [];
|
||||
|
||||
this.composerDivRef = useRef('composerDivRef');
|
||||
|
||||
// Array of all the anchors linked to a single comment
|
||||
this.anchors = this.props.anchors;
|
||||
// Main anchor used for the positioning
|
||||
this.mainAnchor = this.anchors[0];
|
||||
|
||||
this.state = useState({
|
||||
loading: true,
|
||||
commenting: false,
|
||||
thread: this.props.thread,
|
||||
isResolved: this.props.isResolved,
|
||||
smallUI: !this.props.forceFullSize &&
|
||||
(
|
||||
this.props.record.data.full_width ||
|
||||
this.isSmallUINeeded()
|
||||
),
|
||||
// This array contains the text of the anchors selected by the user to create the comment.
|
||||
// Each element of this array corresponds to the text of a paragraph.
|
||||
quotes: [],
|
||||
knowledgeThreadId: this.props.knowledgeThreadId,
|
||||
top: this.props.top
|
||||
});
|
||||
|
||||
useChildSubEnv({
|
||||
closeThread: this.resolveComment.bind(this),
|
||||
openThread: this.unresolveComment.bind(this),
|
||||
isResolved: this.isResolved
|
||||
});
|
||||
|
||||
this.placeholder = _t('Add a Comment...');
|
||||
|
||||
useBus(this.uiService.bus, 'resize', () => {
|
||||
this.state.smallUI = this.isSmallUINeeded();
|
||||
});
|
||||
useBus(this.env.bus, `KNOWLEDGE_COMMENT_${this.state.knowledgeThreadId}:COMPUTE_POSITION`, ({detail}) => {
|
||||
if (detail) {
|
||||
this.anchors = detail.newAnchors;
|
||||
this.mainAnchor = this.anchors[0];
|
||||
}
|
||||
const oldTop = this.top;
|
||||
this.setTopPosition();
|
||||
// Only update the position if we have a significant change to avoid flickers
|
||||
if (Math.abs(oldTop - this.top) > this.mainAnchor.getBoundingClientRect().height) {
|
||||
this.state.top = this.top;
|
||||
}
|
||||
});
|
||||
useBus(this.env.bus, 'KNOWLEDGE:CLOSE_COMMENT', () => this.state.commenting = false);
|
||||
useBus(this.env.bus, 'mail.thread/onUpdate', ({ detail }) => {
|
||||
if (detail.data.isLoaded && detail.thread.model === 'knowledge.article.thread') {
|
||||
this.state.lastPartnerId = detail.thread.messages.at(-1)?.author.id || this.userService.partnerId;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* This observer checks for mutation inside the previous sibling of the box and the box itself.
|
||||
*/
|
||||
this.observer = new MutationObserver(() => {
|
||||
if (this.targetRef.el) {
|
||||
const oldTop = this.top;
|
||||
if (!this.mainAnchor?.parentNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setTopPosition();
|
||||
// Only update the position if we have a significant change to avoid flickers
|
||||
if (Math.abs(oldTop - this.top) > this.mainAnchor?.getBoundingClientRect().height) {
|
||||
this.state.top = this.top;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
useExternalListener(window, 'click', this.endCommenting.bind(this));
|
||||
|
||||
onWillUpdateProps((nextProps) => {
|
||||
this.anchors = nextProps.anchors;
|
||||
this.mainAnchor = nextProps.anchors[0];
|
||||
});
|
||||
onMounted(() => {
|
||||
this.state.loading = false;
|
||||
this.highlightComment(false);
|
||||
// If the ID of the thread is 0 it is considered as a virtual comment, it is in the
|
||||
// phase of being created but doesn't exist currently. This way we avoid creating records
|
||||
// that will not
|
||||
if (this.props.isCreationMode) {
|
||||
this.state.commenting = true;
|
||||
if (this.state.smallUI) {
|
||||
this.openPopover({target: this.targetRef.el});
|
||||
} else {
|
||||
this.composerDivRef.el.querySelector('textarea')?.focus();
|
||||
}
|
||||
this.highlightComment(true);
|
||||
}
|
||||
const paragraphs = new Map();
|
||||
for (const anchor of this.anchors) {
|
||||
const closestParagraph = closestBlock(anchor);
|
||||
if (!paragraphs.has(closestParagraph)) {
|
||||
paragraphs.set(closestParagraph, anchor.textContent);
|
||||
} else {
|
||||
paragraphs.set(closestParagraph, paragraphs.get(closestParagraph) + anchor.textContent);
|
||||
}
|
||||
}
|
||||
this.state.quotes = Array.from(paragraphs.values());
|
||||
if (!this.props.forceFullSize) {
|
||||
if (this.state.isResolved && this.mainAnchor?.classList.contains('highlighted-comment')) {
|
||||
this.resolveComment();
|
||||
return;
|
||||
}
|
||||
this.setTopPosition();
|
||||
if (this.targetRef.el) {
|
||||
this.state.top = this.top;
|
||||
this.targetRef.el.closest('.o_scroll_view_lg').addEventListener('scroll', this.onScroll.bind(this));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onPatched(() => {
|
||||
if (this.state.commenting && !this.state.smallUI) {
|
||||
this.composerDivRef.el?.querySelector('textarea')?.focus();
|
||||
}
|
||||
const { messages } = this.threadService.store.get(this.state.thread.localId);
|
||||
if (messages.length && messages.every((message) => !message.body)) {
|
||||
this.props.destroyComment(this.state.knowledgeThreadId, this.mainAnchor, true);
|
||||
return;
|
||||
}
|
||||
if (!this.props.forceFullSize) {
|
||||
this.setTopPosition();
|
||||
this.updateHorizontalDimensions();
|
||||
if (this.targetRef.el) {
|
||||
this.state.top = this.top;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onWillStart(async () => {
|
||||
this.messages = await this.threadService.fetchMessages(this.state.thread);
|
||||
if (this.state.smallUI && this.state.thread) {
|
||||
const nonEmptyMessages = this.messages.some((message) => message.body);
|
||||
if (this.messages.length && !nonEmptyMessages) {
|
||||
this.props.destroyComment(this.state.knowledgeThreadId, this.mainAnchor, true);
|
||||
return;
|
||||
}
|
||||
this.state.lastPartnerId = this.messages.length ? this.messages.at(-1)?.author.id : this.userService.partnerId;
|
||||
} else if (!this.state.thread) {
|
||||
this.state.lastPartnerId = this.userService.partnerId;
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!this.state.smallUI && this.props.knowledgeThreadId) {
|
||||
const observer = new MutationObserver((mutationList) => {
|
||||
const mutationsWithEmptyThread = mutationList.some((mutation) => mutation.target.querySelector('.o-mail-Thread-empty'));
|
||||
if (mutationsWithEmptyThread) {
|
||||
this.props.destroyComment(this.state.knowledgeThreadId, this.mainAnchor, true);
|
||||
return;
|
||||
}
|
||||
});
|
||||
observer.observe(this.targetRef.el.querySelector('.o-mail-Thread'), {subtree:true, childList: true});
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
|
||||
}, () => []);
|
||||
|
||||
useEffect((isFullWidth) => {
|
||||
this.state.smallUI = isFullWidth;
|
||||
this.updateHorizontalDimensions(isFullWidth);
|
||||
}, () => [this.props.record.data.full_width]);
|
||||
|
||||
if (!this.props.forceFullSize) {
|
||||
useEffect(() => {
|
||||
const observer = new ResizeObserver(() => {
|
||||
this.updateHorizontalDimensions();
|
||||
});
|
||||
observer.observe(this.editable.parentElement);
|
||||
return () => observer.disconnect();
|
||||
}, () => []);
|
||||
useEffect((el, sibling, childNodes) => {
|
||||
if (el) {
|
||||
this.observer.observe(el, {subtree: true, childList: true});
|
||||
this.observer.observe(el.parentElement, {childList: true, subtree: true, attributeFilter: ['style']});
|
||||
}
|
||||
let removeEventListener = false;
|
||||
if (sibling) {
|
||||
this.observer.observe(sibling, {subtree: true, attributeFilter: ['style'], attributeOldValue: true});
|
||||
if (sibling.querySelector('.o_knowledge_comment_box_thread')) {
|
||||
this.observer.observe(sibling.querySelector('.o_knowledge_comment_box_thread'), {childList: true, subtree:true});
|
||||
}
|
||||
const composerDivContainer = sibling.querySelector('.o_knowledge_comments_buttons');
|
||||
if (composerDivContainer) {
|
||||
composerDivContainer.addEventListener('input', () => {
|
||||
this.setTopPosition();
|
||||
if (this.targetRef.el) {
|
||||
this.state.top = this.top;
|
||||
}
|
||||
});
|
||||
removeEventListener = () => composerDivContainer.removeEventListener('input', () => {
|
||||
this.setTopPosition();
|
||||
if (this.targetRef.el) {
|
||||
this.state.top = this.top;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
if (removeEventListener) {
|
||||
removeEventListener();
|
||||
}
|
||||
this.observer.disconnect();
|
||||
};
|
||||
}, () => {
|
||||
if (!this.targetRef.el?.previousElementSibling) {
|
||||
return [this.targetRef.el];
|
||||
}
|
||||
return [this.targetRef.el, this.targetRef.el.previousElementSibling];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**======================
|
||||
* Getters
|
||||
*========================**/
|
||||
|
||||
get editable() {
|
||||
return document.querySelector(
|
||||
`.o_field_html .note-editable,
|
||||
.o_field_knowledge_article_html_field .note-editable,
|
||||
.o_field_knowledge_article_html_field .o_readonly`
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Used for the message actions
|
||||
*/
|
||||
get isResolved() {
|
||||
return this.state.isResolved;
|
||||
}
|
||||
|
||||
get textInputContent() {
|
||||
return this.props.knowledgeThreadId && this.state.thread.composer.textInputContent;
|
||||
}
|
||||
|
||||
|
||||
/**======================
|
||||
* Position Computations
|
||||
*========================**/
|
||||
|
||||
isSmallUINeeded() {
|
||||
const maxWidth = this.editable.closest('.o_field_knowledge_article_html_field').getBoundingClientRect().right - this.editable.getBoundingClientRect().right;
|
||||
return maxWidth <= 300;
|
||||
}
|
||||
|
||||
onScroll() {
|
||||
if (this.props.forceFullSize || !this.targetRef.el) {
|
||||
return;
|
||||
}
|
||||
const oldTop = this.top;
|
||||
if (!this.mainAnchor?.parentNode) {
|
||||
return;
|
||||
}
|
||||
this.setTopPosition();
|
||||
if (this.targetRef.el && Math.abs(oldTop - this.top) > this.mainAnchor?.getBoundingClientRect().height) {
|
||||
this.targetRef.el.style.top = `${this.top}px`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function computes the vertical position of the comment box and sets the variable this.top
|
||||
* to the new top value that the box should be positioned at.
|
||||
* To compute the position we take into account the top position of the anchor and we check if
|
||||
* at this position we don't intersect with any other box. If that's the case then the new top
|
||||
* position is the bottom of the intersected box, this way we do not have any intersections
|
||||
* between them.
|
||||
*/
|
||||
setTopPosition() {
|
||||
if (!this.targetRef.el) {
|
||||
return;
|
||||
}
|
||||
const rootTop = document.querySelector('.o_knowledge_body').getBoundingClientRect().top;
|
||||
if (!this.mainAnchor?.parentNode) {
|
||||
return;
|
||||
}
|
||||
const targetTop = this.mainAnchor.getBoundingClientRect().top;
|
||||
const newTop = Math.abs(rootTop - targetTop);
|
||||
const sibling = this.targetRef.el.previousElementSibling;
|
||||
if (!sibling) {
|
||||
this.top = Math.round(newTop);
|
||||
return;
|
||||
}
|
||||
const siblingRect = sibling.getBoundingClientRect();
|
||||
const needsRepositioning = targetTop < siblingRect.bottom;
|
||||
// 2 is to add a space between boxes, it is so that they are not directly set one below the other
|
||||
const previousBottom = siblingRect.bottom - rootTop + 2;
|
||||
|
||||
this.top = Math.round(!needsRepositioning ? newTop : previousBottom);
|
||||
}
|
||||
|
||||
/**
|
||||
* This functions redimensions horizontally the Component to let it fit in the right column.
|
||||
* @param {boolean} forcedSmallUI Do we need to force the small UI to the component
|
||||
*/
|
||||
updateHorizontalDimensions(forcedSmallUI=false) {
|
||||
if (!this.targetRef.el) {
|
||||
return;
|
||||
}
|
||||
// 400px is the maxWidth set in the css file for the class .o_knowledge_comment_box.
|
||||
const boxMaxWidth = 400;
|
||||
const limitRight = this.editable.parentElement.getBoundingClientRect().right;
|
||||
const maxWidth = limitRight - this.editable.getBoundingClientRect().right;
|
||||
const commentWidth = this.targetRef.el.getBoundingClientRect().width;
|
||||
this.state.smallUI = !this.props.forceFullSize && (forcedSmallUI || this.isSmallUINeeded());
|
||||
if (this.state.smallUI) {
|
||||
this.state.lastPartnerId = this.messages.at(-1)?.author.id || this.userService.partnerId;
|
||||
this.state.width = '';
|
||||
// 1cm is the size of the margin that was decided to set when an article is not full size but
|
||||
// still triggers the small UI mode. 1rem is the size of the
|
||||
this.state.marginRight = this.props.record.data.full_width ? 'margin-right: 1rem' : 'margin-right: 1cm';
|
||||
return;
|
||||
}
|
||||
if (this.popover.isOpen) {
|
||||
this.popover.close();
|
||||
}
|
||||
this.originalWidth = this.originalWidth ? this.originalWidth : boxMaxWidth;
|
||||
if (maxWidth > boxMaxWidth) {
|
||||
this.state.marginRight = `margin-right: ${ maxWidth/2 - boxMaxWidth/2 }px`;
|
||||
this.state.width = `width: ${ boxMaxWidth }px`;
|
||||
} else {
|
||||
this.state.width = `width: ${ commentWidth - (commentWidth - maxWidth) }px`;
|
||||
this.state.marginRight = this.props.record.data.full_width ? '' : 'margin-right: 1cm';
|
||||
}
|
||||
}
|
||||
|
||||
/**======================
|
||||
* Popover actions
|
||||
*========================**/
|
||||
|
||||
closePopover() {
|
||||
this.popover.close();
|
||||
this.state.commenting = false;
|
||||
this.highlightComment(false);
|
||||
this.mainAnchor?.dispatchEvent(new Event('focusout'));
|
||||
}
|
||||
|
||||
|
||||
openPopover(ev) {
|
||||
if (!this.popover.isOpen) {
|
||||
this.state.commentingId = true;
|
||||
const popoverProps = {
|
||||
knowledgeThreadId: this.state.knowledgeThreadId,
|
||||
record: this.props.record,
|
||||
thread: this.state.thread,
|
||||
closePopover: this.closePopover.bind(this),
|
||||
resolveComment: this.resolveComment.bind(this),
|
||||
insertNewThread: this.insertNewThread.bind(this)
|
||||
};
|
||||
this.popover.open(ev.target.closest('.o_knowledge_comment_box'), popoverProps);
|
||||
}
|
||||
}
|
||||
|
||||
/**======================
|
||||
* Comment Actions
|
||||
*========================**/
|
||||
|
||||
endCommenting(ev) {
|
||||
if (
|
||||
this.state.commenting &&
|
||||
(
|
||||
!ev.target.closest(`.o_knowledge_comment_box[data-id="${this.props.knowledgeThreadId}"]`) &&
|
||||
!(
|
||||
this.props.isCreationMode &&
|
||||
ev.target.closest('.o_knowledge_comment_box') &&
|
||||
!ev.target.closest('.o_knowledge_comment_box').dataset.id
|
||||
)
|
||||
) &&
|
||||
(
|
||||
!ev.target.closest(`.knowledge-thread-comment[data-id="${this.props.knowledgeThreadId}"]`) &&
|
||||
!(
|
||||
this.props.isCreationMode && ev.target.closest('.knowledge-thread-comment') &&
|
||||
!ev.target.closest('.knowledge-thread-comment').dataset.id
|
||||
)
|
||||
) &&
|
||||
ev.target.closest('.o_knowledge_form_view') &&
|
||||
!this.textInputContent
|
||||
) {
|
||||
this.state.commenting = false;
|
||||
this.highlightComment(false);
|
||||
this.targetRef.el.classList.remove('commenting', 'focused-comment');
|
||||
this.targetRef.el.querySelector('a[data-type="cancel"]')?.click();
|
||||
if (!this.state.thread?.messages.length) {
|
||||
if (!this.props.knowledgeThreadId) {
|
||||
this.threadService.clearComposer(this.state.thread.composer);
|
||||
}
|
||||
this.props.destroyComment(this.state.knowledgeThreadId, this.mainAnchor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showAnchors() {
|
||||
this.mainAnchor?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center'
|
||||
});
|
||||
this.highlightComment(true);
|
||||
window.setTimeout(() => this.highlightComment(false), 500);
|
||||
}
|
||||
|
||||
startCommenting(ev) {
|
||||
if (
|
||||
(
|
||||
ev.target.closest(`.comment[data-id="${this.state.knowledgeThreadId}"]`) ||
|
||||
ev.target.closest(`.o_knowledge_comment_box[data-id="${this.state.knowledgeThreadId}"]`)
|
||||
) &&
|
||||
!ev.target.closest('.o-mail-Message-actions, .o-mail-Message-content .o-mail-Composer, .o-mail-MessageReaction, .o-mail-Composer ~ span a')
|
||||
) {
|
||||
this.state.commenting = true;
|
||||
if ( !this.state.isResolved && this.state.smallUI ) {
|
||||
this.openPopover({target: this.targetRef.el});
|
||||
}
|
||||
this.highlightComment(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**========================================================================
|
||||
* Resolution State Thread
|
||||
*========================================================================**/
|
||||
|
||||
highlightComment(isHighlighted) {
|
||||
if (!this.props.forceFullSize) {
|
||||
if (isHighlighted || this.state.commenting) {
|
||||
this.targetRef.el?.classList.add('focused-comment');
|
||||
} else {
|
||||
this.targetRef.el?.classList.remove('focused-comment');
|
||||
}
|
||||
}
|
||||
if (this.anchors) {
|
||||
for (const anchor of this.anchors) {
|
||||
if (isHighlighted || this.state.commenting) {
|
||||
anchor.classList.add('focused-comment');
|
||||
} else {
|
||||
anchor.classList.remove('focused-comment');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function enables the user to resolve a thread. A resolved thread is a thread where the
|
||||
* user considers that the discussion does not need to go any further, thus closing it and making
|
||||
* its box disappear. These closed discussions can still be found in the panel if need be.
|
||||
* @returns
|
||||
*/
|
||||
async resolveComment() {
|
||||
const statusChanged = await this.props.changeCommentResolvedState(this.state.knowledgeThreadId, true);
|
||||
if (!statusChanged) {
|
||||
return;
|
||||
}
|
||||
this.state.isResolved = true;
|
||||
if (this.props.forceFullSize) {
|
||||
this.messages = await this.threadService.fetchNewMessages(this.state.thread);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* This function enables the user to unresolve a thread making it again visible to other users.
|
||||
* @returns
|
||||
*/
|
||||
async unresolveComment() {
|
||||
const statusChanged = await this.props.changeCommentResolvedState(this.state.knowledgeThreadId, false);
|
||||
if (!statusChanged) {
|
||||
return;
|
||||
}
|
||||
this.state.isResolved = false;
|
||||
if (this.props.forceFullSize) {
|
||||
this.messages = await this.threadService.fetchNewMessages(this.state.thread);
|
||||
}
|
||||
}
|
||||
|
||||
/**======================
|
||||
* Comment Creation
|
||||
*========================**/
|
||||
|
||||
async insertNewThread(value, postData) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
const [id] = await this.orm.create(
|
||||
'knowledge.article.thread',
|
||||
[{ article_id: this.props.record.resId }],
|
||||
{}
|
||||
);
|
||||
this.threadService.clearComposer(this.state.thread.composer);
|
||||
this.state.thread = this.threadService.getThread('knowledge.article.thread', id);
|
||||
this.threadService.post(this.state.thread, value, postData);
|
||||
for (const anchor of this.anchors) {
|
||||
anchor.dataset.id = id;
|
||||
}
|
||||
this.highlightComment(false);
|
||||
await this.props.insertNewThread(id, this.state.thread);
|
||||
this.state.updating = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
.o_knowledge_comment_box {
|
||||
width: max-content;
|
||||
max-width: 400px;
|
||||
transition: background-color .2s ease-in-out;
|
||||
background-color: $o-gray-100;
|
||||
|
||||
ul.o_knowledge_comment_anchor {
|
||||
max-height: 4rem;
|
||||
> li {
|
||||
border-left: 2px solid darkgoldenrod;
|
||||
}
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
&:not(.commenting) .o_knowledge_comment_box_thread, &:not(.commenting) ul {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.o_knowledge_comment_resolved {
|
||||
background-color: $o-gray-300;
|
||||
}
|
||||
|
||||
&.focused-comment, &.commenting {
|
||||
background-color: $o-gray-200;
|
||||
z-index: auto;
|
||||
}
|
||||
|
||||
& > div {
|
||||
overflow: auto;
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
& .o_knowledge_comment_small_ui img, i.o-mail-Message-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
.o-mail-Message-avatarContainer {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.o_knowledge_comments_buttons {
|
||||
background-color: $o-gray-200;
|
||||
}
|
||||
}
|
||||
|
||||
.card .o_knowledge_comment_box {
|
||||
max-width: none !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.o_knowledge_comments_popover {
|
||||
width: max-content;
|
||||
max-width: 50vw;
|
||||
max-height: 50vh;
|
||||
z-index: 1000;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<div t-name="knowledge.KnowledgeCommentsThread"
|
||||
t-if="!state.updating"
|
||||
t-on-mouseenter="() => this.highlightComment(true)"
|
||||
t-on-mouseleave="() => this.highlightComment(false)"
|
||||
t-on-click="startCommenting"
|
||||
t-att-data-id="props.knowledgeThreadId"
|
||||
t-att-class="{
|
||||
'o_knowledge_comment_box': true,
|
||||
'o_knowledge_comment_resolved': state.isResolved,
|
||||
'commenting': state.commenting and !state.isResolved,
|
||||
'px-0 mh-25 col-lg m-0': props.forceFullSize,
|
||||
'd-inline-block position-absolute end-100 rounded': !props.forceFullSize,
|
||||
'pt-2': !(state.smallUI || props.record.data.full_width)
|
||||
}"
|
||||
t-attf-style="top: ${this.state.top}px; ${this.state.width}; ${this.state.marginRight};"
|
||||
t-ref="targetRef">
|
||||
<div class="o_knowledge_comment_small_ui"
|
||||
t-if="!props.forceFullSize and state.smallUI"
|
||||
t-on-click="openPopover">
|
||||
<img t-on-click="openPopover" class="rounded p-0 o_object_fit_cover cursor-pointer"
|
||||
t-attf-src="/web/image?model=res.partner&field=avatar_128&id={{ state.lastPartnerId }}"/>
|
||||
</div>
|
||||
<t t-elif="props.anchors.length or props.forceFullSize">
|
||||
<ul t-if="props.forceFullSize" class="o_knowledge_comment_anchor list-unstyled" t-on-click="showAnchors">
|
||||
<t t-foreach="state.quotes" t-as="quote" t-key="quote_index">
|
||||
<li class="ps-2 opacity-75 word-break">
|
||||
<t t-out="quote"/>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
<t t-if="!props.isCreationMode">
|
||||
<div class="p-0 o_knowledge_comment_box_thread">
|
||||
<Thread thread="state.thread" showDates="false"
|
||||
order="'asc'" showEmptyMessage="false" showJumpPresent="false"
|
||||
/>
|
||||
<div class="ms-1">
|
||||
<button t-if="props.forceFullSize and !state.commenting" class="btn ms-5" t-on-click="startCommenting">Reply</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_knowledge_comments_buttons ps-1" t-ref="composerDivRef">
|
||||
<Composer placeholder="placeholder" composer="state.thread.composer"
|
||||
t-if="state.commenting" type="'note'" showFullComposer="false" mode="'extended'"
|
||||
allowUpload="false"/>
|
||||
</div>
|
||||
</t>
|
||||
<div class="o_knowledge_comments_buttons ps-1 emptyThread" t-ref="composerDivRef" t-else="">
|
||||
<KnowledgeCommentCreatorComposer placeholder="placeholder" composer="state.thread.composer"
|
||||
type="'note'" showFullComposer="false" mode="'extended'"
|
||||
allowUpload="false" onPostCallback.bind="insertNewThread"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</templates>
|
||||
@@ -0,0 +1,473 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registry } from '@web/core/registry';
|
||||
import { standardWidgetProps } from '@web/views/widgets/standard_widget_props';
|
||||
import { useBus, useService } from '@web/core/utils/hooks';
|
||||
import { useRecordObserver } from '@web/model/relational_model/utils';
|
||||
import { isZWS } from '@web_editor/js/editor/odoo-editor/src/utils/utils';
|
||||
import { KnowledgeCommentsThread } from '../comment/comment';
|
||||
|
||||
import {
|
||||
Component,
|
||||
useState,
|
||||
onMounted,
|
||||
onPatched,
|
||||
onWillStart,
|
||||
status,
|
||||
useEffect,
|
||||
useRef
|
||||
} from '@odoo/owl';
|
||||
|
||||
|
||||
export class KnowledgeCommentsHandler extends Component {
|
||||
static template = 'knowledge.KnowledgeCommentsHandler';
|
||||
static components = { KnowledgeCommentsThread };
|
||||
static props = { ...standardWidgetProps };
|
||||
|
||||
setup() {
|
||||
this.state = useState({
|
||||
comments: {},
|
||||
creatingThread: false,
|
||||
displayCommentsHandler: true,
|
||||
newlyCreatedComment: false,
|
||||
});
|
||||
useBus(this.env.bus, 'KNOWLEDGE:CREATE_COMMENT_THREAD', this.createCommentThread.bind(this));
|
||||
|
||||
useBus(this.env.bus, 'KNOWLEDGE:CHANGE_COMMENT_STATE', this.changeCommentResolvedState.bind(this));
|
||||
useBus(this.env.bus, 'KNOWLEDGE_COMMENTS_PANELS:TOGGLE_HANDLER', this.toggleHandler.bind(this));
|
||||
useBus(this.env.bus, 'KNOWLEDGE_COMMENTS:CHANGES_DETECTED', this.handleChanges.bind(this));
|
||||
useBus(this.env.bus, 'KNOWLEDGE_COMMENTS_PANEL:CREATE_COMMENT', ({detail}) => {
|
||||
const newComment = detail.newComment;
|
||||
this.state.comments[newComment.knowledgeThreadId] = Object.assign({}, newComment);
|
||||
this.allCommentsThreadRecords.push({
|
||||
id: newComment.knowledgeThreadId,
|
||||
article_id: [newComment.articleId, this.props.record.data.name],
|
||||
is_resolved: newComment.isResolved, write_date: luxon.DateTime.now()
|
||||
});
|
||||
delete this.state.comments['undefined'];
|
||||
this.env.bus.trigger('KNOWLEDGE_WYSIWYG:HISTORY_STEP');
|
||||
});
|
||||
this.root = useRef('root');
|
||||
|
||||
this.boundFunctions = {
|
||||
destroyComment: (id) => this.destroyComment(id),
|
||||
changeCommentResolvedState: this._changeCommentResolvedState.bind(this)
|
||||
};
|
||||
|
||||
this.threadService = useService('mail.thread');
|
||||
this.userService = useService('user');
|
||||
this.orm = useService('orm');
|
||||
this.rpc = useService('rpc');
|
||||
|
||||
useRecordObserver(async (record) => {
|
||||
const allCommentsThread = (record.resId || this.props.record.resId) ? await this.orm.searchRead(
|
||||
'knowledge.article.thread',
|
||||
[['article_id', '=', record.resId]],
|
||||
['id', 'article_id', 'is_resolved', 'write_date']
|
||||
) : [];
|
||||
this.allCommentsThreadRecords = allCommentsThread;
|
||||
this.env.bus.trigger('KNOWLEDGE_COMMENTS_PANEL:SYNCHRONIZE_THREADS', {allCommentsThread : this.allCommentsThreadRecords});
|
||||
this.env.bus.trigger('KNOWLEDGE_COMMENTS_PANEL:DISPLAY_BUTTON', {
|
||||
commentsActive: this.allCommentsThreadRecords && this.allCommentsThreadRecords.length !== 0,
|
||||
displayCommentsPanel: !this.state.displayCommentsHandler
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
onWillStart(async () => {
|
||||
this.state.isInternalUser = await this.userService.hasGroup('base.group_user');
|
||||
this.state.isPortalUser = await this.userService.hasGroup('base.group_portal');
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
this.env.bus.trigger('KNOWLEDGE_COMMENTS_PANEL:SYNCHRONIZE_THREADS', {allCommentsThread : this.allCommentsThreadRecords});
|
||||
this.triggerButton();
|
||||
});
|
||||
|
||||
onPatched(() => {
|
||||
if (this.env.services.action.currentController?.action.context.show_resolved_threads) {
|
||||
this.env.bus.trigger('KNOWLEDGE:TOGGLE_COMMENTS', {forcedMode: 'resolved', displayCommentsPanel: true});
|
||||
this.env.bus.trigger('KNOWLEDGE_COMMENTS_PANEL:DISPLAY_BUTTON', {
|
||||
commentsActive: this.allCommentsThreadRecords && this.allCommentsThreadRecords.length !== 0,
|
||||
displayCommentsPanel: true
|
||||
});
|
||||
this.env.bus.trigger('KNOWLEDGE_COMMENTS_PANEL:SYNCHRONIZE_THREADS', {allCommentsThread : this.allCommentsThreadRecords});
|
||||
this.env.services.action.currentController.action.context.show_resolved_threads = false;
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
this.filterComments();
|
||||
this.allComments = document.querySelectorAll('.knowledge-thread-highlighted-comment');
|
||||
// add listeners for commented text
|
||||
const editable = document.querySelector(
|
||||
'.o_field_html .note-editable,.o_field_knowledge_article_html_field .note-editable,.o_field_knowledge_article_html_field .o_readonly'
|
||||
);
|
||||
if (editable) {
|
||||
this.addHoverListeners(editable.parentElement);
|
||||
}
|
||||
return () => {
|
||||
const editable = document.querySelector(
|
||||
'.o_field_html .note-editable,.o_field_knowledge_article_html_field .note-editable,.o_field_knowledge_article_html_field .o_readonly'
|
||||
);
|
||||
if (editable) {
|
||||
this.removeHoverListeners(editable.parentElement);
|
||||
}
|
||||
};
|
||||
}, () => [this.props.record.resId]);
|
||||
|
||||
useEffect(() => {
|
||||
for (const commentId of Object.keys(this.state.comments)) {
|
||||
this.updateExistingCommentAnchors(parseInt(commentId));
|
||||
}
|
||||
}, () => [this.props.record.data.is_locked, this.props.record.data.user_can_write]);
|
||||
}
|
||||
|
||||
get displayedComments() {
|
||||
return Object.values(this.state.comments).filter((comment) => comment.anchors.length && !comment.isResolved)
|
||||
.sort((commentA, commentB) => {
|
||||
const topDiff = commentA.top - commentB.top;
|
||||
if (!topDiff) {
|
||||
return commentA.anchors[0].getBoundingClientRect().left - commentB.anchors[0].getBoundingClientRect().left;
|
||||
}
|
||||
return topDiff;
|
||||
});
|
||||
}
|
||||
|
||||
async triggerButton() {
|
||||
await this.env.topbarMountedPromise;
|
||||
this.env.bus.trigger('KNOWLEDGE_COMMENTS_PANEL:DISPLAY_BUTTON', {
|
||||
commentsActive: this.allCommentsThreadRecords && this.allCommentsThreadRecords.length !== 0,
|
||||
displayCommentsPanel: !this.state.displayCommentsHandler
|
||||
});
|
||||
}
|
||||
|
||||
/**============================================
|
||||
* Listeners
|
||||
*=============================================**/
|
||||
|
||||
addHoverListeners(editable) {
|
||||
if (editable) {
|
||||
editable.addEventListener('mouseover', this.onMouseOver.bind(this));
|
||||
editable.addEventListener('focusin', this.showAssignedComment.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
removeHoverListeners(editable) {
|
||||
if (editable) {
|
||||
editable.removeEventListener('mouseover', this.onMouseOver.bind(this));
|
||||
editable.removeEventListener('focusin', this.showAssignedComment.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
onMouseOver(ev) {
|
||||
const commentNode = ev.target.closest('.knowledge-thread-highlighted-comment');
|
||||
const relatedNode = ev.relatedTarget?.closest('.knowledge-thread-highlighted-comment');
|
||||
if (relatedNode && relatedNode.dataset.id) {
|
||||
const id = parseInt(relatedNode.dataset.id);
|
||||
if (!this.commentingId && ((commentNode && id !== parseInt(commentNode.dataset.id)) || !commentNode)) {
|
||||
document.querySelectorAll(`.knowledge-thread-comment.knowledge-thread-highlighted-comment[data-id='${id}'], .o_knowledge_comment_box[data-id='${id}']`).forEach((node) => {
|
||||
node.classList.remove('focused-comment');
|
||||
});
|
||||
}
|
||||
}
|
||||
if (commentNode && commentNode.dataset.id && !commentNode.classList.contains('focused-comment')) {
|
||||
const id = parseInt(commentNode.dataset.id);
|
||||
document.querySelectorAll(`.knowledge-thread-comment.knowledge-thread-highlighted-comment[data-id='${id}'], .o_knowledge_comment_box[data-id='${id}']`).forEach((node) => {
|
||||
node.classList.add('focused-comment');
|
||||
});
|
||||
if (!this.state.displayCommentsHandler) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showAssignedComment(ev) {
|
||||
const commentNode = ev.target.closest('.knowledge-thread-highlighted-comment');
|
||||
if (commentNode && commentNode.dataset.id) {
|
||||
const commentingId = parseInt(commentNode.dataset.id);
|
||||
this.commentingId = commentingId;
|
||||
document.querySelector(`.o_knowledge_comment_box[data-id='${commentingId}']`)?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest'
|
||||
});
|
||||
document.querySelector(`.o_knowledge_comment_box[data-id='${commentingId}']`)?.click();
|
||||
} else if (ev.target?.closest('.o_field_knowledge_article_html_field')) {
|
||||
this.commentingId = false;
|
||||
}
|
||||
}
|
||||
|
||||
toggleHandler(ev) {
|
||||
this.state.displayCommentsHandler = ev.detail.displayCommentsHandler;
|
||||
if (this.state.displayCommentsHandler) {
|
||||
this.filterComments();
|
||||
delete this.state.comments['undefined'];
|
||||
}
|
||||
}
|
||||
|
||||
/**============================================
|
||||
* Creation + updates of threads
|
||||
*=============================================**/
|
||||
|
||||
async createCommentThread(event) {
|
||||
const selectedNodes = event.detail.selectedNodes;
|
||||
if (!selectedNodes) {
|
||||
return;
|
||||
}
|
||||
const commentAnchors = selectedNodes.map((selectedNode) => {
|
||||
const commentThreadAnchor = document.createElement('span');
|
||||
commentThreadAnchor.classList.add('knowledge-thread-highlighted-comment', 'knowledge-thread-comment');
|
||||
commentThreadAnchor.setAttribute('tabindex', '-1');
|
||||
commentThreadAnchor.dataset.id = 'undefined';
|
||||
const range = document.createRange();
|
||||
range.setStartBefore(selectedNode);
|
||||
range.setEndAfter(selectedNode);
|
||||
range.surroundContents(commentThreadAnchor);
|
||||
return commentThreadAnchor;
|
||||
});
|
||||
if (!commentAnchors || !commentAnchors.length) {
|
||||
return;
|
||||
}
|
||||
const targetTop = commentAnchors[0].getBoundingClientRect().top;
|
||||
const rootTop = document.querySelector('.o_knowledge_body').getBoundingClientRect().top;
|
||||
const newlyCreatedComment = {
|
||||
isCreationMode: true,
|
||||
articleId: this.props.record.resId,
|
||||
anchors: commentAnchors,
|
||||
top: Math.round(Math.abs(targetTop - rootTop)),
|
||||
thread: this.threadService.getThread('knowledge.article.thread', undefined),
|
||||
isResolved: false,
|
||||
insertNewThread: this.insertNewThread.bind(this),
|
||||
writeDate: luxon.DateTime.now(),
|
||||
};
|
||||
if (!this.state.displayCommentsHandler) {
|
||||
this.env.bus.trigger('KNOWLEDGE_COMMENTS_PANELS:CREATE_COMMENT', {comment: newlyCreatedComment});
|
||||
}
|
||||
this.state.comments['undefined'] = newlyCreatedComment;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function insert a newly created comment inside the comments array to signify
|
||||
* that the comment is indeed created by the user. This way we avoid empty threads inside the
|
||||
* DB.
|
||||
* @param {*} id ID of the created `knowledge.article.thread`.
|
||||
* @param {*} thread Thread object from the thread service inside mail.
|
||||
*/
|
||||
async insertNewThread(id, thread) {
|
||||
// TODO-THJO: Create a service to remove the functions to handle comments
|
||||
this.state.comments[id] = Object.assign({}, this.state.comments['undefined'], {thread: thread, knowledgeThreadId: id, isCreationMode: false});
|
||||
this.allCommentsThreadRecords.push({id: id, article_id: [this.props.record.resId, this.props.record.data.name], is_resolved: false, write_date: luxon.DateTime.now()});
|
||||
this.env.bus.trigger('KNOWLEDGE_COMMENTS_PANELS:CREATE_COMMENT', {comment: this.state.comments[id]});
|
||||
delete this.state.comments['undefined'];
|
||||
|
||||
//* This is needed because we do not have an automatic save mechanism.
|
||||
//* When we create comments without saving, we create a bunch of comments linked to nothing
|
||||
//* => leading to a bunch of unnecessary noise.
|
||||
//TODO: When we add the autosave => clean all the save from comments
|
||||
await this.props.record.save({reload: false});
|
||||
this.env.bus.trigger('KNOWLEDGE_WYSIWYG:HISTORY_STEP');
|
||||
}
|
||||
|
||||
/**
|
||||
* This function filters the comments the comments stored in `this.allCommentsThreadRecords to only get
|
||||
* the ones that are represented in the body of the article. Meaning that there is an anchor in the
|
||||
* body with the id of a thread.
|
||||
*/
|
||||
filterComments() {
|
||||
this.allComments = document.querySelectorAll('.knowledge-thread-comment');
|
||||
this.state.comments = {};
|
||||
if (this.allComments.length) {
|
||||
const rootTop = document.querySelector('.o_knowledge_body').getBoundingClientRect().top;
|
||||
const encounteredThreadIds = {};
|
||||
this.allCommentsThreadRecords.forEach((commentThread) => {
|
||||
encounteredThreadIds[commentThread.id] = true;
|
||||
const anchors = Array.from(document.querySelectorAll(`.knowledge-thread-comment[data-id="${commentThread.id}"]`)).filter((node) => !isZWS(node));
|
||||
const targetTop = anchors[0]?.getBoundingClientRect().top;
|
||||
const comment = {
|
||||
knowledgeThreadId: commentThread.id,
|
||||
articleId:commentThread.article_id[0],
|
||||
isResolved: commentThread.is_resolved,
|
||||
anchors: anchors.length ? anchors : [],
|
||||
top: Math.abs(rootTop - targetTop),
|
||||
thread: this.threadService.getThread('knowledge.article.thread', commentThread.id),
|
||||
destroyComment: this.destroyComment.bind(this),
|
||||
};
|
||||
|
||||
this.changeStyling(anchors, comment.isResolved);
|
||||
|
||||
this.state.comments[commentThread.id] = comment;
|
||||
});
|
||||
this.allComments.forEach((node) => {
|
||||
if (document.contains(node) && node.dataset.id &&!encounteredThreadIds[parseInt(node.dataset.id)]) {
|
||||
const text = document.createTextNode(node.textContent);
|
||||
node.replaceWith(text);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {integer} id ID of the comment to destroy
|
||||
* @param {HTMLElement} [anchor=undefined] The text anchor of the comment being destroyed, in case it doesn't exist.
|
||||
*/
|
||||
async destroyComment(id, anchor=undefined, unlink=false) {
|
||||
this.allCommentsThreadRecords = this.allCommentsThreadRecords.filter((commentThread) => commentThread.id !== id);
|
||||
if (anchor) {
|
||||
const text = document.createTextNode(anchor.textContent);
|
||||
anchor.replaceWith(text);
|
||||
}
|
||||
const toDestroy = this.state.comments[id] || this.state.comments['undefined'];
|
||||
if (!toDestroy) {
|
||||
return;
|
||||
}
|
||||
const commentAnchors = toDestroy.anchors;
|
||||
for (const commentAnchor of commentAnchors) {
|
||||
const text = document.createTextNode(commentAnchor.textContent);
|
||||
commentAnchor.replaceWith(text);
|
||||
}
|
||||
delete this.state.comments[id];
|
||||
delete this.state.comments['undefined'];
|
||||
this.env.bus.trigger('KNOWLEDGE_COMMENTS_PANELS:DELETE_COMMENT', {removedCommentId: id});
|
||||
if (unlink) {
|
||||
await this.orm.unlink('knowledge.article.thread', [id]);
|
||||
}
|
||||
await this.props.record.save();
|
||||
}
|
||||
|
||||
/**============================================
|
||||
* Thread Resolution
|
||||
*=============================================**/
|
||||
|
||||
changeCommentResolvedState(ev) {
|
||||
this._changeCommentResolvedState(ev.detail.threadId, ev.detail.newResolvedState, ev);
|
||||
}
|
||||
|
||||
async _changeCommentResolvedState(id, newResolvedState, ev) {
|
||||
if (!ev) {
|
||||
this.env.bus.trigger('KNOWLEDGE_COMMENTS_PANELS:CHANGE_COMMENT_STATE', {id: id, newResolvedState: newResolvedState});
|
||||
}
|
||||
if (status(this) === 'destroyed') {
|
||||
return;
|
||||
}
|
||||
// calling via the orm to ensure committing in the DB
|
||||
try {
|
||||
await this.orm.call(
|
||||
'knowledge.article.thread',
|
||||
'toggle_thread',
|
||||
[id],
|
||||
{}
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const commentToToggle = this.state.comments[id];
|
||||
if (commentToToggle) {
|
||||
this.state.comments[id].isResolved = newResolvedState;
|
||||
this.changeStyling(commentToToggle.anchors, newResolvedState);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
changeStyling(anchors, newResolvedState) {
|
||||
for (const anchor of anchors) {
|
||||
anchor.classList.remove('focused-comment');
|
||||
anchor.classList.toggle('knowledge-thread-highlighted-comment', !newResolvedState);
|
||||
if (newResolvedState) {
|
||||
anchor.removeAttribute('tabindex');
|
||||
} else {
|
||||
anchor.setAttribute('tabindex', '-1');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**============================================
|
||||
* Changes Handlers
|
||||
*=============================================**/
|
||||
|
||||
handleChanges(event) {
|
||||
const impactedComments = event.detail.impactedComments;
|
||||
for (const impactedComment of impactedComments.values()) {
|
||||
this.updateAnchors(parseInt(impactedComment));
|
||||
}
|
||||
}
|
||||
|
||||
async updateAnchors(id) {
|
||||
const toUpdate = this.state.comments[id];
|
||||
// Comment doesn't exist
|
||||
const anchors = Array.from(document.querySelectorAll(`.knowledge-thread-comment[data-id="${id}"]`)).filter((node) => !isZWS(node) && !node.hasAttribute('data-oe-zws-empty-inline'));
|
||||
if (!toUpdate && anchors.length) {
|
||||
const [searchedComment] = await this.orm.searchRead(
|
||||
'knowledge.article.thread',
|
||||
[
|
||||
['id', '=', id],
|
||||
['message_ids', '!=', false]
|
||||
],
|
||||
['id', 'article_id', 'is_resolved', 'write_date', 'message_ids'],
|
||||
{
|
||||
order: 'write_date DESC'
|
||||
}
|
||||
);
|
||||
if (!searchedComment) {
|
||||
return;
|
||||
}
|
||||
const rootTop = document.querySelector('.o_knowledge_body').getBoundingClientRect().top;
|
||||
const targetTop = anchors[0]?.getBoundingClientRect().top;
|
||||
const comment = {
|
||||
knowledgeThreadId: searchedComment.id,
|
||||
articleId:searchedComment.article_id[0],
|
||||
isResolved: searchedComment.is_resolved,
|
||||
anchors: anchors,
|
||||
top: Math.abs(rootTop - targetTop),
|
||||
thread: this.threadService.getThread('knowledge.article.thread', searchedComment.id),
|
||||
writeDate: searchedComment.write_date
|
||||
};
|
||||
|
||||
this.changeStyling(anchors, comment.isResolved);
|
||||
|
||||
this.state.comments[id] = comment;
|
||||
this.env.bus.trigger('KNOWLEDGE_COMMENTS_PANELS:CREATE_COMMENT', {comment: comment});
|
||||
return;
|
||||
}
|
||||
this.updateExistingCommentAnchors(id);
|
||||
}
|
||||
|
||||
updateExistingCommentAnchors(id) {
|
||||
const toUpdate = this.state.comments[id];
|
||||
if (!toUpdate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const anchors = Array.from(document.querySelectorAll(`.knowledge-thread-comment[data-id="${id}"]`)).filter((node) => !isZWS(node) && !node.hasAttribute('data-oe-zws-empty-inline'));
|
||||
|
||||
// Comment's anchors are no longer in the body
|
||||
if (!anchors.length) {
|
||||
if (toUpdate.anchors.length && toUpdate.anchors[0].parentElement) {
|
||||
toUpdate.anchors[0].remove();
|
||||
}
|
||||
delete this.state.comments[id];
|
||||
return;
|
||||
}
|
||||
// We do not need to update the anchors since they are all technically present => No anchor was removed
|
||||
if (toUpdate.anchors.length === anchors.length && !isZWS(toUpdate.anchors[0])) {
|
||||
this.env.bus.trigger(`KNOWLEDGE_COMMENT_${id}:COMPUTE_POSITION`, {newAnchors: anchors});
|
||||
return;
|
||||
}
|
||||
|
||||
for (const anchor of anchors) {
|
||||
if (isZWS(anchor) || !anchor.childNodes.length || anchor.textContent === '') {
|
||||
anchor.remove();
|
||||
}
|
||||
}
|
||||
toUpdate.anchors = anchors;
|
||||
this.state.comments[id] = toUpdate;
|
||||
this.env.bus.trigger(`KNOWLEDGE_COMMENT_${id}:COMPUTE_POSITION`, {newAnchors: anchors});
|
||||
}
|
||||
}
|
||||
|
||||
export const knowledgeCommentsHandler = {
|
||||
component: KnowledgeCommentsHandler,
|
||||
additionalClasses: ['position-absolute', 'top-0', 'start-100', 'd-print-none']
|
||||
};
|
||||
|
||||
registry.category('view_widgets').add('knowledge_comments_handler', knowledgeCommentsHandler);
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<div t-name="knowledge.KnowledgeCommentsHandler" t-ref="root">
|
||||
<t t-if="state.displayCommentsHandler and props.record.data.user_permission">
|
||||
<t t-foreach="displayedComments" t-as="comment" t-key="comment.knowledgeThreadId">
|
||||
<KnowledgeCommentsThread
|
||||
t-props="comment" record="props.record"
|
||||
|
||||
changeCommentResolvedState="boundFunctions.changeCommentResolvedState"
|
||||
destroyComment="boundFunctions.destroyComment"
|
||||
/>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
</templates>
|
||||
@@ -0,0 +1,200 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registry } from '@web/core/registry';
|
||||
import { standardWidgetProps } from '@web/views/widgets/standard_widget_props';
|
||||
import { useBus, useService } from '@web/core/utils/hooks';
|
||||
|
||||
import { KnowledgeCommentsThread } from '../comment/comment';
|
||||
|
||||
import { Component, onWillStart, useRef, useState } from '@odoo/owl';
|
||||
|
||||
|
||||
export class KnowledgeArticleCommentsPanel extends Component {
|
||||
static template = 'knowledge.KnowledgeArticleCommentsPanel';
|
||||
static components = { KnowledgeCommentsThread };
|
||||
static props = { ...standardWidgetProps };
|
||||
|
||||
setup() {
|
||||
this.state = useState({
|
||||
displayCommentsPanel: false,
|
||||
commentMode: 'unresolved',
|
||||
comments: {},
|
||||
mounted: false
|
||||
});
|
||||
|
||||
this.threadRecords = [];
|
||||
|
||||
this.commentsActive = false;
|
||||
|
||||
this.root = useRef('root');
|
||||
|
||||
this.boundFunctions = {
|
||||
destroyComment: (id, anchor, unlink=false) => this.destroyComment(id, anchor, unlink),
|
||||
changeCommentResolvedState:
|
||||
this._changeCommentResolvedState.bind(this)
|
||||
};
|
||||
|
||||
this.threadService = useService('mail.thread');
|
||||
this.orm = useService('orm');
|
||||
this.userService = useService('user');
|
||||
|
||||
onWillStart(async () => {
|
||||
this.state.isInternalUser = await this.userService.hasGroup('base.group_user');
|
||||
});
|
||||
|
||||
useBus(this.env.bus, 'KNOWLEDGE:TOGGLE_COMMENTS', this.toggleComments.bind(this));
|
||||
useBus(this.env.bus, 'KNOWLEDGE_COMMENTS_PANELS:CHANGE_COMMENT_STATE', this.changeCommentResolvedState.bind(this));
|
||||
useBus(this.env.bus, 'KNOWLEDGE_COMMENTS_PANELS:CREATE_COMMENT', this.insertNewThreadFromHandler.bind(this));
|
||||
useBus(this.env.bus, 'KNOWLEDGE_COMMENTS_PANEL:SYNCHRONIZE_THREADS', this.loadData.bind(this));
|
||||
useBus(this.env.bus, 'KNOWLEDGE_COMMENTS_PANELS:DELETE_COMMENT', ({detail}) => {
|
||||
delete this.state.comments[detail.removedCommentId];
|
||||
this.threadRecords = this.threadRecords.filter((record) => record.id !== detail.removedCommentId);
|
||||
});
|
||||
}
|
||||
|
||||
get displayedComments() {
|
||||
return Object.values(this.state.comments)
|
||||
.sort((threadA, threadB) => threadB.writeDate - threadA.writeDate)
|
||||
.filter((thread) => {
|
||||
if (this.state.commentMode === 'all') {
|
||||
return true;
|
||||
}
|
||||
return this.state.commentMode === 'resolved' ? thread.isResolved : !thread.isResolved;
|
||||
});
|
||||
}
|
||||
|
||||
loadData({detail}) {
|
||||
const allCommentsThread = detail.allCommentsThread;
|
||||
this.commentsActive = allCommentsThread && allCommentsThread.length !== 0;
|
||||
if (allCommentsThread) {
|
||||
this.threadRecords = allCommentsThread;
|
||||
}
|
||||
this.fillThreads();
|
||||
}
|
||||
|
||||
sortRecords() {
|
||||
this.threadRecords.sort((recordA, recordB) => {
|
||||
const dateA = new Date(recordA.write_date);
|
||||
const dateB = new Date(recordB.write_date);
|
||||
return dateB - dateA;
|
||||
});
|
||||
}
|
||||
|
||||
fillThreads () {
|
||||
this.sortRecords();
|
||||
this.state.comments = {};
|
||||
this.threadRecords.filter(
|
||||
(record) => {
|
||||
if (this.state.commentMode === 'all'){
|
||||
return true;
|
||||
}
|
||||
return this.state.commentMode === 'resolved' ? record.is_resolved : !record.is_resolved;
|
||||
}).forEach(
|
||||
(record) => {
|
||||
const allCommentsAnchors = document.querySelectorAll(`.knowledge-thread-comment[data-id="${record.id}"]`);
|
||||
const thread = {
|
||||
thread: this.threadService.getThread('knowledge.article.thread', record.id),
|
||||
knowledgeThreadId: record.id,
|
||||
isResolved: record.is_resolved,
|
||||
anchors: allCommentsAnchors.length ? Array.from(allCommentsAnchors) : [],
|
||||
articleId: this.props.record.resId
|
||||
};
|
||||
this.state.comments[record.id] = thread;
|
||||
});
|
||||
}
|
||||
|
||||
insertNewThreadFromHandler(event) {
|
||||
const newlyCreatedComment = event.detail.comment;
|
||||
this.state.commentMode = 'unresolved';
|
||||
if (newlyCreatedComment.isCreationMode) {
|
||||
this.state.comments['undefined'] = newlyCreatedComment;
|
||||
return;
|
||||
}
|
||||
if (!this.commentsActive) {
|
||||
this.commentsActive = true;
|
||||
this.env.bus.trigger('KNOWLEDGE_COMMENTS_PANEL:DISPLAY_BUTTON', {commentsActive: true, displayCommentsPanel: this.state.displayCommentsPanel});
|
||||
}
|
||||
this.threadRecords.unshift({
|
||||
id: newlyCreatedComment.knowledgeThreadId,
|
||||
article_id: this.props.record.resId,
|
||||
is_resolved: newlyCreatedComment.isResolved,
|
||||
write_date: newlyCreatedComment.writeDate
|
||||
});
|
||||
this.state.comments[newlyCreatedComment.knowledgeThreadId || 'undefined'] = newlyCreatedComment;
|
||||
}
|
||||
|
||||
insertNewThread(id, thread) {
|
||||
const newCommentThread = Object.assign({}, this.state.comments['undefined'], {knowledgeThreadId: id, thread: thread, isCreationMode: false});
|
||||
this.threadRecords.unshift({
|
||||
id: newCommentThread.knowledgeThreadId,
|
||||
article_id: this.props.record.resId,
|
||||
is_resolved: false,
|
||||
write_date: luxon.DateTime.now(),
|
||||
});
|
||||
this.state.comments[id] = newCommentThread;
|
||||
this.env.bus.trigger('KNOWLEDGE_COMMENTS_PANEL:CREATE_COMMENT', {newComment: newCommentThread});
|
||||
delete this.state.comments['undefined'];
|
||||
}
|
||||
|
||||
_onChangeMode(event) {
|
||||
const input = event.target;
|
||||
this.state.commentMode = input.value;
|
||||
this.fillThreads();
|
||||
}
|
||||
|
||||
toggleComments(event) {
|
||||
this.fillThreads();
|
||||
this.root.el?.parentElement?.classList[event.detail.displayCommentsPanel ? 'remove' : 'add']('d-none');
|
||||
this.state.commentMode = event.detail.forcedMode || this.state.commentMode;
|
||||
this.state.displayCommentsPanel = event.detail.displayCommentsPanel;
|
||||
this.env.bus.trigger('KNOWLEDGE_COMMENTS_PANELS:TOGGLE_HANDLER', {displayCommentsHandler: !this.state.displayCommentsPanel});
|
||||
}
|
||||
|
||||
changeCommentResolvedState(ev) {
|
||||
const { id, newResolvedState } = ev.detail;
|
||||
this._changeCommentResolvedState(id, newResolvedState, ev);
|
||||
}
|
||||
|
||||
async _changeCommentResolvedState(threadId, newResolvedState, ev=undefined) {
|
||||
if (!ev) {
|
||||
try{
|
||||
this.env.bus.trigger('KNOWLEDGE:CHANGE_COMMENT_STATE', {threadId: threadId, newResolvedState: newResolvedState});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
this.threadRecords.find((element) => element.id === threadId)['is_resolved'] = newResolvedState;
|
||||
if (this.state.commentMode !== 'all') {
|
||||
delete this.state.comments[threadId];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async destroyComment(threadId, _anchor, unlink) {
|
||||
let threadToDestroy = this.state.comments[threadId];
|
||||
if (!threadToDestroy) {
|
||||
if (!this.state.comments['undefined']) {
|
||||
return;
|
||||
}
|
||||
threadToDestroy = this.state.comments['undefined'];
|
||||
}
|
||||
for (const commentAnchor of threadToDestroy.anchors) {
|
||||
const text = document.createTextNode(commentAnchor.textContent);
|
||||
commentAnchor.replaceWith(text);
|
||||
}
|
||||
this.threadRecords = this.threadRecords.filter(record => record.id !== threadId);
|
||||
delete this.state.comments[threadId];
|
||||
this.sortRecords();
|
||||
if (unlink) {
|
||||
this.orm.unlink('knowledge.article.thread', [threadId]);
|
||||
}
|
||||
await this.props.record.save();
|
||||
}
|
||||
}
|
||||
|
||||
export const knowledgeCommentsPanel = {
|
||||
component: KnowledgeArticleCommentsPanel,
|
||||
additionalClasses: ['d-none', 'col-12', 'col-lg-4', 'border-start', 'd-print-none']
|
||||
};
|
||||
|
||||
registry.category('view_widgets').add('knowledge_comments_panel', knowledgeCommentsPanel);
|
||||
@@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<div t-name="knowledge.KnowledgeArticleCommentsPanel" t-ref="root">
|
||||
<div t-if="state.displayCommentsPanel" class="o_knowledge_comments_panel">
|
||||
<select t-att-value="state.commentMode" t-on-change="_onChangeMode" class="form-select border-0 my-2">
|
||||
<option value="all">All Comments</option>
|
||||
<option value="resolved">Resolved Comments</option>
|
||||
<option value="unresolved">Unresolved Comments</option>
|
||||
</select>
|
||||
<t t-if="state.creatingComment">
|
||||
<div class="card ps-0 mb-2">
|
||||
<p class="card-body p-0 m-0 row">
|
||||
<KnowledgeCommentsThread
|
||||
t-props="state.creatingComment"
|
||||
record="props.record"
|
||||
changeCommentResolvedState="boundFunctions.changeCommentResolvedState"
|
||||
forceFullSize="true"
|
||||
destroyComment="boundFunctions.destroyComment"
|
||||
insertNewThread.bind="insertNewThread"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="Object.values(state.comments).length">
|
||||
<t t-foreach="displayedComments"
|
||||
t-as="thread" t-key="thread.knowledgeThreadId">
|
||||
<div class="card ps-0 mb-2">
|
||||
<p class="card-body p-0 m-0 row">
|
||||
<KnowledgeCommentsThread
|
||||
t-props="thread"
|
||||
record="props.record"
|
||||
changeCommentResolvedState="boundFunctions.changeCommentResolvedState"
|
||||
forceFullSize="true"
|
||||
destroyComment="boundFunctions.destroyComment"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<div t-elif="!this.state.creatingComment" class="o_comments_helper text-center p-4 d-flex flex-column align-items-center justify-content-end">
|
||||
<p class="o_view_nocontent_smiling_face"/>
|
||||
<h3>Nothing going on!</h3>
|
||||
<p class="mb-0">
|
||||
Highlight content and use the <i class="fa fa-commenting"/> button to add comments
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</templates>
|
||||
@@ -0,0 +1,87 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { Composer } from "@mail/core/common/composer";
|
||||
import { KnowledgeCommentCreatorComposer } from '../composer/composer';
|
||||
import { Thread } from "@mail/core/common/thread";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
import {
|
||||
Component,
|
||||
useChildSubEnv,
|
||||
useEffect,
|
||||
useExternalListener,
|
||||
useRef,
|
||||
useState
|
||||
} from '@odoo/owl';
|
||||
|
||||
/**
|
||||
* This Popover is used in the case of the editable zone being too small to display the full comment
|
||||
* boxes, or when an article is set to full_width.
|
||||
* It is called when a user clicks on the image that will take the place of the comment box.
|
||||
*/
|
||||
export class KnowledgeCommentsPopover extends Component {
|
||||
|
||||
static template = 'knowledge.KnowledgeCommentsPopover';
|
||||
static props = {
|
||||
thread: {type: Object, optional: true},
|
||||
closePopover: Function,
|
||||
resolveComment: Function,
|
||||
record: Object,
|
||||
knowledgeThreadId: {type: Number, optional: true},
|
||||
close: Function,
|
||||
onKeyUp: {type: Function, optional: true},
|
||||
insertNewThread: {type: Function, optional: true}
|
||||
};
|
||||
static components = { Composer, Thread, KnowledgeCommentCreatorComposer };
|
||||
|
||||
setup() {
|
||||
this.threadService = useService('mail.thread');
|
||||
|
||||
this.composerRef = useRef('ComposerRef');
|
||||
|
||||
this.state = useState({
|
||||
thread: this.props.thread
|
||||
});
|
||||
if (this.state.thread) {
|
||||
this.state.thread.composer.type = 'note';
|
||||
}
|
||||
|
||||
useChildSubEnv({
|
||||
closeThread: this.resolveComment.bind(this),
|
||||
});
|
||||
|
||||
useExternalListener(window, 'click', this.endCommenting.bind(this));
|
||||
|
||||
useEffect((el) => {
|
||||
el?.querySelector('textarea').focus();
|
||||
}, () => [this.composerRef.el]);
|
||||
}
|
||||
|
||||
closePopover() {
|
||||
this.props.closePopover();
|
||||
}
|
||||
|
||||
resolveComment() {
|
||||
this.props.resolveComment(this.props.knowledgeThreadId);
|
||||
}
|
||||
|
||||
onKeyUp(ev) {
|
||||
this.state.textInput = ev.target.value;
|
||||
this.props.onKeyUp(ev);
|
||||
}
|
||||
|
||||
endCommenting(ev) {
|
||||
if (
|
||||
!ev.target.closest(`.o_knowledge_comment_box[data-id="${this.props.knowledgeThreadId}"]`) &&
|
||||
!ev.target.closest(`.knowledge-thread-comment[data-id="${this.props.knowledgeThreadId}"]`) &&
|
||||
ev.target.closest('.o_knowledge_form_view')
|
||||
) {
|
||||
this.closePopover();
|
||||
}
|
||||
}
|
||||
|
||||
async insertNewThread(value, postData) {
|
||||
await this.props.insertNewThread(value, postData);
|
||||
this.closePopover();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<div t-name="knowledge.KnowledgeCommentsPopover" class="o_knowledge_comments_popover">
|
||||
<Thread t-if="props.knowledgeThreadId"
|
||||
thread="state.thread"
|
||||
order="'asc'"
|
||||
showDates="false"
|
||||
showEmptyMessage="false"
|
||||
showJumpPresent="false"
|
||||
/>
|
||||
<div t-ref="ComposerRef">
|
||||
<Composer t-if="props.knowledgeThreadId"
|
||||
composer="state.thread.composer" mode="'extended'" type="'note'"
|
||||
showFullComposer="false" allowUpload="false"/>
|
||||
<div t-else="" class="pt-1">
|
||||
<KnowledgeCommentCreatorComposer placeholder="placeholder"
|
||||
composer="state.thread.composer" type="'note'" showFullComposer="false"
|
||||
mode="'extended'" allowUpload="false" onPostCallback.bind="insertNewThread"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</templates>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user