knowledge module
This commit is contained in:
parent
eee154da0c
commit
adb500009f
|
|
@ -0,0 +1,36 @@
|
|||
# -*- 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 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 _uninstall_knowledge(env):
|
||||
env.cr.execute("""
|
||||
DROP TEXT SEARCH CONFIGURATION IF EXISTS knowledge_config CASCADE;
|
||||
""")
|
||||
env.cr.execute("""
|
||||
DROP TEXT SEARCH DICTIONARY IF EXISTS knowledge_dictionary;
|
||||
""")
|
||||
|
||||
|
||||
def _init_private_article_per_user(env):
|
||||
env['res.users'].search([('partner_share', '=', False)])._generate_tutorial_articles()
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
# -*- 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', # still needed for backend history functions
|
||||
'digest',
|
||||
'html_editor',
|
||||
'mail',
|
||||
'portal',
|
||||
'web_unsplash',
|
||||
'web_hierarchy',
|
||||
],
|
||||
'data': [
|
||||
'data/article_templates.xml',
|
||||
'data/digest_data.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',
|
||||
'uninstall_hook': '_uninstall_knowledge',
|
||||
'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',
|
||||
('after', 'web/static/src/views/form/form_controller.xml', 'knowledge/static/src/xml/form_controller.xml'),
|
||||
'knowledge/static/src/xml/**/*',
|
||||
'knowledge/static/src/components/**/*',
|
||||
'knowledge/static/src/editor/**/*',
|
||||
'knowledge/static/src/comments/**/*',
|
||||
'knowledge/static/src/mail/**/*',
|
||||
'knowledge/static/src/search_model/**/*',
|
||||
('after', 'web/static/src/views/form/form_controller.js', 'knowledge/static/src/web/form_controller_patch.js'),
|
||||
'knowledge/static/src/web/**/*',
|
||||
'knowledge/static/src/js/knowledge_controller.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/webclient/**/*',
|
||||
'knowledge/static/src/views/**/*',
|
||||
('remove', 'knowledge/static/src/views/hierarchy/**'),
|
||||
'knowledge/static/src/services/**/*',
|
||||
'knowledge/static/src/macros/**/*',
|
||||
],
|
||||
'web.assets_backend_lazy': [
|
||||
'knowledge/static/src/views/hierarchy/**',
|
||||
],
|
||||
'web.assets_backend_lazy_dark': [
|
||||
'knowledge/static/src/scss/knowledge_views.dark.scss',
|
||||
],
|
||||
"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/js/knowledge_utils.js',
|
||||
],
|
||||
'web.assets_unit_tests': [
|
||||
'knowledge/static/tests/**/*',
|
||||
('remove', 'knowledge/static/tests/legacy/**/*'),
|
||||
('remove', 'knowledge/static/tests/tours/**/*'),
|
||||
],
|
||||
'web.assets_tests': [
|
||||
'knowledge/static/tests/tours/**/*',
|
||||
],
|
||||
# 'web.qunit_suite_tests': [
|
||||
# # 'knowledge/static/tests/legacy/**/*', # TODO: conversion
|
||||
# ],
|
||||
'web.tests_assets': [
|
||||
'knowledge/static/tests/legacy/mock_services.js',
|
||||
],
|
||||
'knowledge.webclient': [
|
||||
('include', 'web.assets_backend'),
|
||||
# knowledge webclient overrides
|
||||
'knowledge/static/src/portal_webclient/**/*',
|
||||
'web/static/src/start.js',
|
||||
],
|
||||
'web.assets_web_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,58 @@
|
|||
# -*- 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 odoo.addons.mail.tools.discuss import Store
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
|
||||
class ArticleThreadController(KnowledgeController):
|
||||
|
||||
@http.route('/knowledge/thread/create', type='json', auth='user')
|
||||
def create_thread(self, article_id, article_anchor_text="", fields=["id", "article_anchor_text"]):
|
||||
article_thread = request.env['knowledge.article.thread'].create({
|
||||
'article_id': article_id,
|
||||
'article_anchor_text': article_anchor_text,
|
||||
})
|
||||
return {field: article_thread[field] for field in fields}
|
||||
|
||||
@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("/knowledge/threads/messages", methods=["POST"], type="json", auth="user")
|
||||
def mail_threads_messages(self, thread_model, thread_ids, limit=30):
|
||||
thread_ids = [int(thread_id) for thread_id in thread_ids]
|
||||
output = {}
|
||||
for thread_id in thread_ids:
|
||||
domain = self._prepare_thread_messages_domain(thread_model, thread_id)
|
||||
# TODO ABD optimize duration. Currently very slow because of mail.message._to_store
|
||||
res = request.env["mail.message"]._message_fetch(domain, limit=limit)
|
||||
messages = res.pop("messages")
|
||||
output[thread_id] = {
|
||||
**res,
|
||||
"data": Store(messages, for_current_user=True).get_result(),
|
||||
"messages": Store.many_ids(messages),
|
||||
}
|
||||
return output
|
||||
|
||||
def _prepare_thread_messages_domain(self, thread_model, thread_id):
|
||||
return [
|
||||
("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
|
||||
]
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import requests
|
||||
import werkzeug
|
||||
|
||||
from odoo.addons.web_unsplash.controllers import main
|
||||
from odoo import http
|
||||
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.
|
||||
"""
|
||||
if not article.has_access('write'):
|
||||
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,288 @@
|
|||
# -*- 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):
|
||||
if article.id and show_resolved_threads:
|
||||
action_id = request.env.ref('knowledge.knowledge_article_action_form_show_resolved').id
|
||||
return request.redirect(f'/odoo/action-{action_id}/{article.id}')
|
||||
return request.redirect(f'/odoo/knowledge/{article.id or "new"}')
|
||||
|
||||
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,
|
||||
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'] if not member['partner_share'] or partner_id == request.env.user.partner_id.id or request.env.user._is_internal() else False,
|
||||
'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,358 @@
|
|||
<?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-948702291173">
|
||||
<span style="font-size: 14px;">Check this box to indicate it's done</span>
|
||||
</li>
|
||||
<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/⌘+Z to undo any change</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? Check the video below 👇</span>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<div data-embedded-props='{"videoId":"uEdaZRqa1FQ","platform":"youtube","params":{"rel":0}}'
|
||||
data-embedded="video"/>
|
||||
<p><br /></p>
|
||||
<div data-embedded="clipboard">
|
||||
<div class="d-flex">
|
||||
<div class="o_embedded_clipboard_label align-middle">Clipboard</div>
|
||||
</div>
|
||||
<div data-embedded-editable="clipboardContent">
|
||||
<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 data-embedded="file" data-embedded-props='{
|
||||
"fileData":{
|
||||
"extension": "pdf",
|
||||
"filename": "Odoo Survival Guide",
|
||||
"mimetype": "application/pdf",
|
||||
"name": "Odoo Survival Guide.pdf",
|
||||
"type": "url",
|
||||
"url": "/knowledge/static/src/demo/Onboarding.pdf?download=true"
|
||||
}
|
||||
}'/>
|
||||
<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/ftp" target="_blank"><span style="font-weight: bolder;"><font class="text-o-color-1">X</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/ftp" target="_blank"><span style="font-weight: bolder;"><font class="text-o-color-1">Facebook</font></span></a>.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
</data></odoo>
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<odoo>
|
||||
<data>
|
||||
<record id="digest_tip_knowledge_0" model="digest.tip">
|
||||
<field name="name">Tip: A Knowledge well kept</field>
|
||||
<field name="sequence">4100</field>
|
||||
<field name="group_id" ref="base.group_user"/>
|
||||
<field name="tip_description" type="html">
|
||||
<div>
|
||||
<p class="tip_title">Tip: A Knowledge well kept</p>
|
||||
<p class="tip_content">Did you know that access rights can be defined per user on any Knowledge Article?</p>
|
||||
<img src="https://download.ftpcdn.com/digests/knowledge/static/src/img/18-knowledge-article-acl.gif" width="540" class="illustration_border"/>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
<record id="digest_tip_knowledge_1" model="digest.tip">
|
||||
<field name="name">Tip: Use Clipboards to easily inject repetitive content</field>
|
||||
<field name="sequence">4200</field>
|
||||
<field name="group_id" ref="base.group_user"/>
|
||||
<field name="tip_description" type="html">
|
||||
<div>
|
||||
<p class="tip_title">Tip: Use Clipboards to easily inject repetitive content</p>
|
||||
<p class="tip_content">Use the /clipboard command on a Knowledge Article and get going.</p>
|
||||
<img src="https://download.ftpcdn.com/digests/knowledge/static/src/img/18-knowledge-article-clipboard.gif" width="540" class="illustration_border"/>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
<record id="digest_tip_knowledge_2" model="digest.tip">
|
||||
<field name="name">Tip: Be on the same page</field>
|
||||
<field name="sequence">4300</field>
|
||||
<field name="group_id" ref="base.group_user"/>
|
||||
<field name="tip_description" type="html">
|
||||
<div>
|
||||
<p class="tip_title">Tip: Be on the same page</p>
|
||||
<p class="tip_content">Start working together on any Knowledge Article by sharing your article with others.</p>
|
||||
<img src="https://download.ftpcdn.com/digests/knowledge/static/src/img/18-knowledge-article-collaboration.gif" width="540" class="illustration_border"/>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<?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="path">knowledge</field>
|
||||
<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
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.
|
||||
|
||||
Note: 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,143 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.tools import html2plaintext
|
||||
|
||||
|
||||
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'
|
||||
|
||||
_ANCHOR_TEXT_MAX_LENGTH = 1200
|
||||
|
||||
article_anchor_text = fields.Text("Anchor Text",
|
||||
help="The original highlighted anchor text, giving initial context if that text is modified or removed afterwards."
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# CRUD METHODS
|
||||
# ===========================================================================
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if 'article_anchor_text' in vals:
|
||||
article_anchor_text = html2plaintext(vals['article_anchor_text'])
|
||||
vals['article_anchor_text'] = (article_anchor_text[:self._ANCHOR_TEXT_MAX_LENGTH] + '...') \
|
||||
if len(article_anchor_text) > self._ANCHOR_TEXT_MAX_LENGTH else article_anchor_text
|
||||
|
||||
return super(KnowledgeArticleThread, self.with_context(mail_create_nolog=True)).create(vals_list)
|
||||
|
||||
def write(self, vals):
|
||||
if 'is_resolved' in vals:
|
||||
self.ensure_one()
|
||||
if 'article_anchor_text' in vals:
|
||||
article_anchor_text = html2plaintext(vals['article_anchor_text'])
|
||||
vals['article_anchor_text'] = (article_anchor_text[:self._ANCHOR_TEXT_MAX_LENGTH] + '...') \
|
||||
if len(article_anchor_text) > self._ANCHOR_TEXT_MAX_LENGTH else article_anchor_text
|
||||
return super().write(vals)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# THREAD OVERRIDES
|
||||
# ==========================================================================
|
||||
|
||||
def message_post(self, **kwargs):
|
||||
"""This function overrides the 'mail.thread' message_post in order to control what portal
|
||||
users that have access to an article can post through a thread message.
|
||||
|
||||
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.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.env.user._is_portal() and self.article_id.user_has_access:
|
||||
authorized_keys = {'body', 'partner_ids', 'author_id', 'attachment_ids'}
|
||||
return super().message_post(
|
||||
**{key: kwargs.get(key) for key in authorized_keys},
|
||||
message_type='comment', subtype_xmlid='mail.mt_comment'
|
||||
)
|
||||
kwargs.update({'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.
|
||||
"""
|
||||
if not kwargs.get('msg_vals', {}).get('partner_ids', []):
|
||||
return
|
||||
kwargs['msg_vals'] = {**kwargs.get('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(self, message, msg_vals, **kwargs):
|
||||
recipients_data = super()._notify_get_recipients(message, msg_vals, **kwargs)
|
||||
recipients_data = [data for data in recipients_data if data['id'] in msg_vals.get('partner_ids', [])]
|
||||
|
||||
return recipients_data
|
||||
|
||||
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,48 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import _, models, api
|
||||
|
||||
|
||||
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
|
||||
|
||||
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 %s', user.name),
|
||||
})
|
||||
|
||||
if articles_to_create:
|
||||
self.env['knowledge.article'].sudo().create(articles_to_create)
|
||||
|
|
@ -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 |
|
|
@ -0,0 +1,442 @@
|
|||
import { user } from "@web/core/user";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { usePopover } from "@web/core/popover/popover_hook";
|
||||
import { Composer } from "@mail/core/common/composer";
|
||||
import { KnowledgeThread } from "../../mail/thread/knowledge_thread";
|
||||
import { scrollTo } from "@web/core/utils/scrolling";
|
||||
import { isBrowserChrome } from "@web/core/browser/feature_detection";
|
||||
import { KnowledgeCommentsPopover } from "../comments_popover/comments_popover";
|
||||
import { KnowledgeCommentCreatorComposer } from "../../mail/composer/composer";
|
||||
import { CommentAnchorText } from "./comment_anchor_text";
|
||||
import { imageUrl } from "@web/core/utils/urls";
|
||||
import {
|
||||
Component,
|
||||
onWillStart,
|
||||
useSubEnv,
|
||||
useRef,
|
||||
useEffect,
|
||||
useState,
|
||||
onWillDestroy,
|
||||
} from "@odoo/owl";
|
||||
import { effect } from "@web/core/utils/reactive";
|
||||
import { batched } from "@web/core/utils/timing";
|
||||
|
||||
const DEFAULT_ANCHOR_TEXT_SIZE = 50;
|
||||
export const MIN_THREAD_WIDTH = 300;
|
||||
|
||||
export class KnowledgeCommentsThread extends Component {
|
||||
static components = {
|
||||
CommentAnchorText,
|
||||
Composer,
|
||||
KnowledgeThread,
|
||||
KnowledgeCommentCreatorComposer,
|
||||
};
|
||||
static props = {
|
||||
threadId: { type: String },
|
||||
horizontalDimensions: { type: Object, optional: true },
|
||||
top: { type: Number, optional: true },
|
||||
readonly: { type: Boolean, optional: true },
|
||||
};
|
||||
static template = "knowledge.KnowledgeCommentsThread";
|
||||
|
||||
setup() {
|
||||
this.commentsService = useService("knowledge.comments");
|
||||
this.threadScrollableRef = useRef("threadScrollableRef");
|
||||
this.targetRef = useRef("targetRef");
|
||||
this.composerRef = useRef("composerRef");
|
||||
this.commentsState = useState(this.commentsService.getCommentsState());
|
||||
let previousThreadId;
|
||||
this.alive = true;
|
||||
effect(
|
||||
batched((state) => {
|
||||
if (!this.alive) {
|
||||
return;
|
||||
}
|
||||
if (previousThreadId !== state.activeThreadId) {
|
||||
if (previousThreadId === this.props.threadId && this.editorThread) {
|
||||
this.editorThread.onActivate(new CustomEvent("knowledge.deactivateThread"));
|
||||
}
|
||||
previousThreadId = state.activeThreadId;
|
||||
}
|
||||
}),
|
||||
[this.commentsState]
|
||||
);
|
||||
onWillDestroy(() => {
|
||||
this.alive = false;
|
||||
});
|
||||
this.state = useState({
|
||||
hasFullAnchorText: false,
|
||||
});
|
||||
useSubEnv({
|
||||
// We need to unset the chatter inside the env of the child Components
|
||||
// because this Object contains values and methods that are linked to the form view's
|
||||
// main chatter. By doing this we distinguish the main chatter from the comments.
|
||||
inChatter: false,
|
||||
chatter: false,
|
||||
closeThread: this.updateResolveState.bind(this, true),
|
||||
inKnowledge: true,
|
||||
isResolved: this.isResolved.bind(this),
|
||||
openThread: this.updateResolveState.bind(this, false),
|
||||
});
|
||||
this.popover = usePopover(KnowledgeCommentsPopover, {
|
||||
closeOnClickAway: true,
|
||||
onClose: () => {
|
||||
this.onClosePopover();
|
||||
},
|
||||
env: this.env,
|
||||
position: "left-start",
|
||||
popoverClass: "o_knowledge_comments_popover",
|
||||
});
|
||||
const onActivate = (ev) => {
|
||||
switch (ev.type) {
|
||||
case "click":
|
||||
this.activateThread();
|
||||
break;
|
||||
case "knowledge.deactivateThread":
|
||||
this.focusOut();
|
||||
break;
|
||||
}
|
||||
};
|
||||
const onFocus = (ev) => {
|
||||
switch (ev.type) {
|
||||
case "mouseenter":
|
||||
this.focusIn();
|
||||
break;
|
||||
case "mouseleave":
|
||||
this.focusOut();
|
||||
break;
|
||||
}
|
||||
};
|
||||
useEffect(
|
||||
() => {
|
||||
const editorThread = this.editorThread;
|
||||
if (editorThread) {
|
||||
editorThread.onActivateMap.set("main", onActivate);
|
||||
editorThread.onFocusMap.set("main", onFocus);
|
||||
}
|
||||
return () => {
|
||||
if (editorThread) {
|
||||
editorThread.onActivateMap.delete(onActivate);
|
||||
editorThread.onFocusMap.delete(onFocus);
|
||||
}
|
||||
};
|
||||
},
|
||||
() => [this.editorThread]
|
||||
);
|
||||
let wasActive;
|
||||
useEffect(
|
||||
() => {
|
||||
if (this.isActive && !wasActive && !this.smallUI) {
|
||||
const scrollable = this.threadScrollableRef.el;
|
||||
const composer = this.composerRef.el;
|
||||
if (scrollable && composer) {
|
||||
const composerRect = composer.getBoundingClientRect();
|
||||
scrollable.scrollBy({
|
||||
top: composerRect.height,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (this.isActive !== wasActive) {
|
||||
wasActive = this.isActive;
|
||||
}
|
||||
},
|
||||
() => [this.isActive, this.smallUI]
|
||||
);
|
||||
if (this.commentsState.displayMode === "handler") {
|
||||
const setTargetHeight = (target) => {
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
if (!(this.props.threadId in this.env.threadHeights)) {
|
||||
this.env.threadHeights[this.props.threadId] = {
|
||||
height: undefined,
|
||||
};
|
||||
}
|
||||
this.env.threadHeights[this.props.threadId].height = targetRect.height;
|
||||
};
|
||||
useEffect(
|
||||
() => {
|
||||
if (this.targetRef.el) {
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.target) {
|
||||
setTargetHeight(entry.target);
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(this.targetRef.el);
|
||||
setTargetHeight(this.targetRef.el);
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
},
|
||||
() => [this.targetRef.el]
|
||||
);
|
||||
useEffect(
|
||||
() => {
|
||||
if (
|
||||
this.editorThread &&
|
||||
this.editorThread.isNew &&
|
||||
this.targetRef.el &&
|
||||
this.isActive &&
|
||||
this.smallUI
|
||||
) {
|
||||
if (this.props.threadId !== "undefined") {
|
||||
this.editorThread.isNew = false;
|
||||
}
|
||||
this.openPopover();
|
||||
}
|
||||
},
|
||||
() => [this.targetRef.el, this.isActive, this.smallUI, this.editorThread]
|
||||
);
|
||||
useEffect(
|
||||
() => {
|
||||
if (!this.smallUI && this.popover.isOpen) {
|
||||
this.popover.close();
|
||||
}
|
||||
},
|
||||
() => [this.smallUI]
|
||||
);
|
||||
}
|
||||
if (this.props.threadId === "undefined") {
|
||||
onWillStart(() => {
|
||||
this.commentsService.loadThreads([this.props.threadId]);
|
||||
});
|
||||
} else {
|
||||
useEffect(
|
||||
() => {
|
||||
if (!(this.props.threadId in this.commentsState.threadRecords)) {
|
||||
this.commentsService.loadRecords(this.env.model.root.resId, {
|
||||
threadId: this.props.threadId,
|
||||
});
|
||||
}
|
||||
},
|
||||
() => [this.commentsState.articleId, this.commentsState.displayMode]
|
||||
);
|
||||
useEffect(
|
||||
() => {
|
||||
if (
|
||||
this.props.threadId in this.commentsState.threadRecords &&
|
||||
!(this.props.threadId in this.commentsState.threads)
|
||||
) {
|
||||
this.commentsService.loadThreads([this.props.threadId]);
|
||||
this.thread.knowledgePreLoading = true;
|
||||
this.thread.fetchNewMessages().then(() => {
|
||||
if (this.editorThread?.isProtected()) {
|
||||
return;
|
||||
}
|
||||
let isEmpty = true;
|
||||
for (const message of this.thread.messages) {
|
||||
if (message.message_type === "comment" && message.body.toString()) {
|
||||
isEmpty = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isEmpty) {
|
||||
this.commentsService.deleteThread(this.props.threadId);
|
||||
}
|
||||
});
|
||||
this.thread.knowledgePreLoading = false;
|
||||
}
|
||||
},
|
||||
() => [this.threadRecord]
|
||||
);
|
||||
}
|
||||
onWillDestroy(() => {
|
||||
if (this.isActive) {
|
||||
this.commentsState.activeThreadId = undefined;
|
||||
}
|
||||
this.commentsState.focusedThreads.delete(this.props.threadId);
|
||||
});
|
||||
}
|
||||
|
||||
get hasLoaded() {
|
||||
return (
|
||||
this.props.threadId === "undefined" ||
|
||||
(this.props.threadId in this.commentsState.threadRecords &&
|
||||
this.props.threadId in this.commentsState.threads)
|
||||
);
|
||||
}
|
||||
|
||||
get hasAllDimensions() {
|
||||
return (
|
||||
this.props.top !== undefined &&
|
||||
this.props.horizontalDimensions !== undefined &&
|
||||
this.props.horizontalDimensions.left !== undefined &&
|
||||
this.props.horizontalDimensions.width !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
get style() {
|
||||
if (this.commentsState.displayMode === "panel") {
|
||||
return "";
|
||||
}
|
||||
return `
|
||||
position: absolute;
|
||||
top: ${this.props.top}px;
|
||||
left: ${this.props.horizontalDimensions.left}px;
|
||||
width: ${this.props.horizontalDimensions.width}px;
|
||||
transition: top 0.3s, left 0.2s, filter 0.2s;
|
||||
z-index: ${this.isActive ? 1 : "auto"};
|
||||
filter: ${
|
||||
!this.smallUI ||
|
||||
this.hasFocus ||
|
||||
(!this.commentsState.activeThreadId && !this.commentsState.focusedThreads.size)
|
||||
? "none"
|
||||
: "grayscale(50%) contrast(50%)"
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
/**@see EditorThreadInfo */
|
||||
get editorThread() {
|
||||
return this.commentsState.editorThreads[this.props.threadId];
|
||||
}
|
||||
|
||||
get authorUrl() {
|
||||
if (this.thread?.messages?.length) {
|
||||
return this.thread.messages.at(-1).author.avatarUrl;
|
||||
}
|
||||
return imageUrl("res.users", user.userId, "avatar_128");
|
||||
}
|
||||
|
||||
get anchorText() {
|
||||
let text = this.fullAnchorText;
|
||||
const brIndex = text.indexOf("<br>");
|
||||
const excludeIndex = brIndex === -1 ? DEFAULT_ANCHOR_TEXT_SIZE : brIndex;
|
||||
if (text.length > excludeIndex) {
|
||||
text = text.substring(0, excludeIndex) + "...";
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
get fullAnchorText() {
|
||||
let text;
|
||||
if (!this.editorThread) {
|
||||
text = this.threadRecord?.article_anchor_text || "";
|
||||
} else {
|
||||
text = this.editorThread.anchorText;
|
||||
}
|
||||
return text.replaceAll("\n", "<br>");
|
||||
}
|
||||
|
||||
get hasFocus() {
|
||||
return this.commentsState.hasFocus(this.props.threadId);
|
||||
}
|
||||
|
||||
get isActive() {
|
||||
return this.commentsState.activeThreadId === this.props.threadId;
|
||||
}
|
||||
|
||||
get showReadMore() {
|
||||
const anchorText = this.anchorText;
|
||||
const fullAnchorText = this.fullAnchorText;
|
||||
return fullAnchorText.length > 0 && anchorText.length !== fullAnchorText.length;
|
||||
}
|
||||
|
||||
/**@see Thread */
|
||||
get thread() {
|
||||
return this.commentsState.threads[this.props.threadId];
|
||||
}
|
||||
|
||||
get threadRecord() {
|
||||
return this.commentsState.threadRecords[this.props.threadId];
|
||||
}
|
||||
|
||||
get smallUI() {
|
||||
return (
|
||||
this.commentsState.displayMode === "handler" &&
|
||||
this.props.horizontalDimensions.width < MIN_THREAD_WIDTH
|
||||
);
|
||||
}
|
||||
|
||||
activateThread() {
|
||||
this.commentsState.activeThreadId = this.props.threadId;
|
||||
if (this.smallUI) {
|
||||
this.openPopover();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Used for the message actions
|
||||
*/
|
||||
isResolved() {
|
||||
if (this.props.threadId === "undefined") {
|
||||
return false;
|
||||
}
|
||||
return this.threadRecord.is_resolved;
|
||||
}
|
||||
|
||||
onClick(ev) {
|
||||
if (this.editorThread) {
|
||||
this.editorThread.onActivate(ev);
|
||||
} else {
|
||||
this.activateThread();
|
||||
}
|
||||
}
|
||||
|
||||
focusIn() {
|
||||
this.commentsState.focusedThreads.add(this.props.threadId);
|
||||
}
|
||||
|
||||
focusOut() {
|
||||
this.commentsState.focusedThreads.delete(this.props.threadId);
|
||||
}
|
||||
|
||||
onMouseEnter(ev) {
|
||||
if (this.editorThread) {
|
||||
this.editorThread.onFocus(ev);
|
||||
} else {
|
||||
this.focusIn();
|
||||
}
|
||||
}
|
||||
|
||||
onMouseLeave(ev) {
|
||||
if (this.editorThread) {
|
||||
this.editorThread.onFocus(ev);
|
||||
} else {
|
||||
this.focusOut();
|
||||
}
|
||||
}
|
||||
|
||||
openPopover() {
|
||||
if (!this.popover.isOpen) {
|
||||
const popoverProps = {
|
||||
threadId: this.props.threadId,
|
||||
};
|
||||
if (this.targetRef.el) {
|
||||
this.popover.open(this.targetRef.el, popoverProps);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onClosePopover() {
|
||||
this.focusOut();
|
||||
}
|
||||
|
||||
showEditorAnchor() {
|
||||
if (!this.editorThread) {
|
||||
return;
|
||||
}
|
||||
if (isBrowserChrome()) {
|
||||
scrollTo(this.editorThread.beaconPair.start, {
|
||||
behavior: "smooth",
|
||||
});
|
||||
} else {
|
||||
this.editorThread.beaconPair.start.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async updateResolveState(value) {
|
||||
const changed = await this.commentsService.updateResolveState(this.props.threadId, value);
|
||||
if (changed && this.commentsState.displayMode === "panel") {
|
||||
await this.thread.fetchNewMessages();
|
||||
}
|
||||
}
|
||||
|
||||
onCreateThreadCallback(thread) {
|
||||
if (thread) {
|
||||
this.commentsState.editorThreads[thread.id]?.select();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
.o_knowledge_comments_handler {
|
||||
.o_knowledge_comment_box {
|
||||
max-width: 400px;
|
||||
.o_knowledge_comment_background {
|
||||
display: flex;
|
||||
&:not(.o_knowledge_comment_small_ui) {
|
||||
background-color: $o-gray-100;
|
||||
}
|
||||
&.o_knowledge_comment_small_ui > img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
.o_knowledge_comment_container {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
&.o_focused_thread {
|
||||
.o_knowledge_comment_background:not(.o_knowledge_comment_small_ui) {
|
||||
background-color: $o-gray-200;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_knowledge_comments_panel {
|
||||
.card.o_knowledge_comment_card_borderless {
|
||||
border: none;
|
||||
}
|
||||
.o_knowledge_comment_box {
|
||||
.o_knowledge_comment_background {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
background-color: $o-gray-100;
|
||||
.o_knowledge_comment_anchor {
|
||||
> div {
|
||||
border-left: 2px solid darkgoldenrod;
|
||||
}
|
||||
}
|
||||
.o_knowledge_comment_container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
&.o_focused_thread {
|
||||
.o_knowledge_comment_background {
|
||||
background-color: $o-gray-200;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_knowledge_comments_panel, .o_knowledge_comments_handler {
|
||||
.o_knowledge_comment_box {
|
||||
.o_knowledge_comment_background {
|
||||
max-height: 50vh;
|
||||
transition: background-color .2s ease-in-out;
|
||||
.o_knowledge_sticky_composer {
|
||||
background-color: $o-gray-200;
|
||||
}
|
||||
.o-mail-Message-avatarContainer {
|
||||
background-color: transparent !important;
|
||||
.o-mail-Message-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.o_focused_thread {
|
||||
&:not(.commenting) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o-overlay-item:has( .o_knowledge_comments_popover) {
|
||||
z-index: 1029; // FileViewer's modal is at 1030
|
||||
}
|
||||
|
||||
.o_knowledge_comments_popover {
|
||||
max-height: 50vh !important;
|
||||
max-width: Min(400px, 80vw) !important;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
.o_knowledge_comment_scrollable {
|
||||
background-color: $o-gray-100;
|
||||
}
|
||||
.o_knowledge_sticky_composer {
|
||||
background-color: $o-gray-100;
|
||||
}
|
||||
}
|
||||
|
||||
.o_knowledge_comment_container, .o_knowledge_comments_popover {
|
||||
.o_knowledge_comment_scrollable {
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.o_knowledge_sticky_composer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
.o-mail-AttachmentCard {
|
||||
grid-column-start: span 12 !important;
|
||||
grid-column-end: auto !important;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
<templates xml:space="preserve">
|
||||
<div t-name="knowledge.KnowledgeCommentsThread"
|
||||
t-if="hasLoaded and (hasAllDimensions or commentsState.displayMode === 'panel')"
|
||||
t-ref="targetRef"
|
||||
t-on-mouseenter="onMouseEnter"
|
||||
t-on-mouseleave="onMouseLeave"
|
||||
t-on-click="onClick"
|
||||
t-att-data-thread-id="props.threadId"
|
||||
class="o_knowledge_comment_box"
|
||||
t-att-class="{
|
||||
'o_knowledge_comment_resolved': isResolved(),
|
||||
'commenting': isActive,
|
||||
'o_focused_thread': hasFocus,
|
||||
'px-0 mh-25 col-lg m-0': commentsState.displayMode === 'panel',
|
||||
'pb-2': commentsState.displayMode === 'handler',
|
||||
}"
|
||||
t-att-style="style"
|
||||
>
|
||||
<div class="o_knowledge_comment_small_ui o_knowledge_comment_background"
|
||||
t-if="smallUI"
|
||||
t-on-click="onClick">
|
||||
<img class="rounded p-0 o_object_fit_cover cursor-pointer"
|
||||
t-att-src="authorUrl"/>
|
||||
</div>
|
||||
<div t-elif="editorThread or commentsState.displayMode === 'panel'"
|
||||
class="o_knowledge_comment_background rounded">
|
||||
<div t-if="commentsState.displayMode === 'panel' and fullAnchorText.length"
|
||||
class="o_knowledge_comment_anchor list-unstyled ps-1 pt-1"
|
||||
t-att-class="{
|
||||
'mb-0': showReadMore,
|
||||
'cursor-pointer': editorThread,
|
||||
}"
|
||||
t-on-click="showEditorAnchor">
|
||||
<div class="px-2 opacity-75 word-break">
|
||||
<CommentAnchorText anchorText="state.hasFullAnchorText ? fullAnchorText : anchorText"/>
|
||||
</div>
|
||||
</div>
|
||||
<t t-if="commentsState.displayMode === 'panel' and showReadMore">
|
||||
<button t-if="!state.hasFullAnchorText" class="btn btn-sm btn-link" t-on-click="() => this.state.hasFullAnchorText = true">
|
||||
Show More
|
||||
</button>
|
||||
<button t-else="" class="btn btn-sm btn-link" t-on-click="() => this.state.hasFullAnchorText = false">
|
||||
Show Less
|
||||
</button>
|
||||
</t>
|
||||
<div t-if="props.threadId !== 'undefined'" class="o_knowledge_comment_container">
|
||||
<div class="o_knowledge_comment_scrollable" t-ref="threadScrollableRef">
|
||||
<KnowledgeThread thread="thread" showDates="false" scrollRef="threadScrollableRef"
|
||||
order="'asc'" showEmptyMessage="false" showJumpPresent="false"
|
||||
/>
|
||||
<div class="o_knowledge_sticky_composer rounded px-2" t-ref="composerRef">
|
||||
<Composer t-if="isActive" placeholder.translate="Add a Comment..." composer="thread.composer"
|
||||
type="'note'" showFullComposer="false" mode="'extended'"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div t-else="" class="pt-2 px-2 flex-grow-1" t-ref="composerRef">
|
||||
<KnowledgeCommentCreatorComposer placeholder.translate="Add a Comment..." composer="thread.composer"
|
||||
type="'note'" showFullComposer="false" mode="'extended'" autofocus="true"
|
||||
allowUpload="false" onPostCallback="commentsService.createThread"
|
||||
onCreateThreadCallback.bind="onCreateThreadCallback"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { Component, onWillUpdateProps } from "@odoo/owl";
|
||||
|
||||
export class CommentAnchorText extends Component {
|
||||
static template = "knowledge.CommentAnchorText";
|
||||
static props = {
|
||||
anchorText: { String },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.anchorTextArray = this.props.anchorText.split("<br>");
|
||||
onWillUpdateProps((newProps) => {
|
||||
this.anchorTextArray = newProps.anchorText.split("<br>");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<templates xml:space="preserve">
|
||||
<t t-name="knowledge.CommentAnchorText">
|
||||
<t t-foreach="anchorTextArray" t-as="anchorFragment" t-key="anchorFragment_index">
|
||||
<t t-out="anchorFragment"/>
|
||||
<t t-if="!anchorFragment_last">
|
||||
<br/>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,603 @@
|
|||
import { leftPos, rightPos } from "@html_editor/utils/position";
|
||||
import { EditorThreadInfo } from "./editor_thread_info";
|
||||
import { throttleForAnimation } from "@web/core/utils/timing";
|
||||
import { childNodes } from "@html_editor/utils/dom_traversal";
|
||||
|
||||
function binarySearch(comparator, needle, array) {
|
||||
let first = 0;
|
||||
let last = array.length - 1;
|
||||
while (first <= last) {
|
||||
const mid = (first + last) >> 1;
|
||||
const c = comparator(needle, array[mid]);
|
||||
if (c > 0) {
|
||||
first = mid + 1;
|
||||
} else if (c < 0) {
|
||||
last = mid - 1;
|
||||
} else {
|
||||
return mid;
|
||||
}
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
export function compareDOMPosition(a, b) {
|
||||
const compare = a.compareDocumentPosition(b);
|
||||
if (compare & 2) {
|
||||
// b before a
|
||||
return 1;
|
||||
} else if (compare & 4) {
|
||||
// a before b
|
||||
return -1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function compareBeaconsThreadIds(a, b) {
|
||||
const id = (beacon) => parseInt(beacon.dataset.id) || -1;
|
||||
const bound = (beacon) => (beacon.dataset.oeType === "threadBeaconStart" ? 0 : 1);
|
||||
const idA = id(a);
|
||||
const boundA = bound(a);
|
||||
const idB = id(b);
|
||||
const boundB = bound(b);
|
||||
if (idA > idB) {
|
||||
return 1;
|
||||
} else if (idB > idA) {
|
||||
return -1;
|
||||
} else if (boundA > boundB) {
|
||||
return 1;
|
||||
} else if (boundB > boundA) {
|
||||
return -1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export class CommentBeaconManager {
|
||||
constructor({
|
||||
commentsState,
|
||||
document,
|
||||
overlayContainer,
|
||||
peerId,
|
||||
readonly = false,
|
||||
source,
|
||||
onStep = () => {},
|
||||
removeBeacon = () => {},
|
||||
setSelection = () => {},
|
||||
} = {}) {
|
||||
this.document = document;
|
||||
this.source = source;
|
||||
this.overlayContainer = overlayContainer;
|
||||
this.beacons = [];
|
||||
this.searchBeaconIndex = binarySearch.bind(undefined, compareDOMPosition);
|
||||
this.beaconsByThreadId = [];
|
||||
this.searchBeaconIndexByThreadId = binarySearch.bind(undefined, compareBeaconsThreadIds);
|
||||
this.bogusBeacons = new Set();
|
||||
this.beaconPairs = {};
|
||||
this.sortedThreadIds = [];
|
||||
this.cleanups = {};
|
||||
this.peerId = peerId;
|
||||
this.readonly = readonly;
|
||||
this.commentsState = commentsState;
|
||||
this.onStep = onStep;
|
||||
this.removeBeacon = removeBeacon;
|
||||
this.setSelection = setSelection;
|
||||
this.drawThreadOverlays = throttleForAnimation(this.drawThreadOverlays.bind(this));
|
||||
this.pendingBeacons = new Set();
|
||||
}
|
||||
|
||||
addBeacons(beacons) {
|
||||
for (const beacon of beacons) {
|
||||
const index = this.searchBeaconIndex(beacon, this.beacons);
|
||||
this.beacons.splice(index, 0, beacon);
|
||||
}
|
||||
}
|
||||
|
||||
deleteBeacons(beacons) {
|
||||
for (const beacon of beacons) {
|
||||
const index = this.searchBeaconIndex(beacon, this.beacons);
|
||||
this.beacons.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
sortThreads() {
|
||||
this.beacons = [...this.source.querySelectorAll(".oe_thread_beacon")];
|
||||
this.beaconsByThreadId = this.beacons.toSorted(compareBeaconsThreadIds);
|
||||
const starts = {};
|
||||
const beaconPairs = {};
|
||||
this.bogusBeacons = new Set();
|
||||
// loop validates that start is before end for beaconPairs
|
||||
for (const beacon of [...this.beacons]) {
|
||||
if (beacon.dataset.oeType === "threadBeaconStart") {
|
||||
// Don't consider other peers "undefined" beacons
|
||||
if (beacon.dataset.id !== "undefined" || beacon.dataset.peerId === this.peerId) {
|
||||
starts[beacon.dataset.id] = {
|
||||
start: beacon,
|
||||
};
|
||||
}
|
||||
this.bogusBeacons.add(beacon);
|
||||
} else if (beacon.dataset.id in starts) {
|
||||
const beaconPair = starts[beacon.dataset.id];
|
||||
delete starts[beacon.dataset.id];
|
||||
beaconPair.end = beacon;
|
||||
if (!this.validate(beaconPair)) {
|
||||
this.bogusBeacons.add(beaconPair.end);
|
||||
} else {
|
||||
this.preserveExistingBeaconPair(beaconPair);
|
||||
this.removeDuplicate(beaconPair.start);
|
||||
this.removeDuplicate(beaconPair.end);
|
||||
this.bogusBeacons.delete(beaconPair.start);
|
||||
beaconPairs[beacon.dataset.id] = beaconPair;
|
||||
}
|
||||
} else {
|
||||
this.bogusBeacons.add(beacon);
|
||||
}
|
||||
}
|
||||
for (const beacon of [...this.bogusBeacons]) {
|
||||
// preserve peer "undefined" beacons
|
||||
if (beacon.dataset.id === "undefined" && beacon.dataset.peerId !== this.peerId) {
|
||||
this.bogusBeacons.delete(beacon);
|
||||
}
|
||||
}
|
||||
for (const beacon of this.pendingBeacons) {
|
||||
// preserve pending beacons
|
||||
this.bogusBeacons.delete(beacon);
|
||||
}
|
||||
const threadIds = new Set(Object.keys(beaconPairs));
|
||||
for (const threadId of Object.keys(this.beaconPairs)) {
|
||||
if (!threadIds.has(threadId)) {
|
||||
this.cleanupThread(threadId);
|
||||
}
|
||||
}
|
||||
const threadIdsToSort = [];
|
||||
for (const threadId of threadIds) {
|
||||
if (!(threadId in this.beaconPairs)) {
|
||||
this.beaconPairs[threadId] = beaconPairs[threadId];
|
||||
} else {
|
||||
if (this.beaconPairs[threadId].start !== beaconPairs[threadId].start) {
|
||||
this.beaconPairs[threadId].start = beaconPairs[threadId].start;
|
||||
}
|
||||
if (this.beaconPairs[threadId].end !== beaconPairs[threadId].end) {
|
||||
this.beaconPairs[threadId].end = beaconPairs[threadId].end;
|
||||
}
|
||||
}
|
||||
let editorThread =
|
||||
this.commentsState.editorThreads[threadId] ||
|
||||
this.commentsState.disabledEditorThreads[threadId];
|
||||
if (!editorThread) {
|
||||
editorThread = new EditorThreadInfo({
|
||||
beaconPair: this.beaconPairs[threadId],
|
||||
threadId,
|
||||
computeTextMap: this.computeTextMap.bind(this),
|
||||
removeBeaconPair: (beaconPair) => {
|
||||
this.cleanupThread(beaconPair.start.dataset.id);
|
||||
this.removeBeacon(beaconPair.start);
|
||||
this.removeBeacon(beaconPair.end);
|
||||
this.onStep();
|
||||
},
|
||||
setSelectionInBeaconPair: (beaconPair) => {
|
||||
if (this.validate(beaconPair)) {
|
||||
const [anchorNode, anchorOffset] = rightPos(beaconPair.start);
|
||||
this.setSelection({
|
||||
anchorNode,
|
||||
anchorOffset,
|
||||
});
|
||||
}
|
||||
},
|
||||
setBeaconPairId: (beaconPair, id) => {
|
||||
beaconPair.start.dataset.id = id;
|
||||
beaconPair.end.dataset.id = id;
|
||||
this.onStep();
|
||||
},
|
||||
enableBeaconPair: (beaconPair) => {
|
||||
if (this.isDisabled(beaconPair.start)) {
|
||||
this.enable(beaconPair.start);
|
||||
}
|
||||
if (this.isDisabled(beaconPair.end)) {
|
||||
this.enable(beaconPair.end);
|
||||
}
|
||||
const threadId = beaconPair.start.dataset.id;
|
||||
const editorThread = this.commentsState.disabledEditorThreads[threadId];
|
||||
if (editorThread) {
|
||||
this.commentsState.editorThreads[threadId] = editorThread;
|
||||
delete this.commentsState.disabledEditorThreads[threadId];
|
||||
}
|
||||
this.onStep();
|
||||
},
|
||||
disableBeaconPair: (beaconPair) => {
|
||||
if (!this.isDisabled(beaconPair.start)) {
|
||||
this.disable(beaconPair.start);
|
||||
}
|
||||
if (!this.isDisabled(beaconPair.end)) {
|
||||
this.disable(beaconPair.end);
|
||||
}
|
||||
const threadId = beaconPair.start.dataset.id;
|
||||
const editorThread = this.commentsState.editorThreads[threadId];
|
||||
if (editorThread) {
|
||||
this.commentsState.disabledEditorThreads[threadId] = editorThread;
|
||||
delete this.commentsState.editorThreads[threadId];
|
||||
}
|
||||
this.onStep();
|
||||
},
|
||||
isOwned: (beaconPair) => {
|
||||
if (!this.peerId) {
|
||||
return true;
|
||||
} else {
|
||||
const peerId = beaconPair.start.dataset.peerId;
|
||||
return !peerId || this.peerId === peerId;
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
editorThread.beaconPair = this.beaconPairs[threadId];
|
||||
}
|
||||
if (
|
||||
this.isDisabled(this.beaconPairs[threadId].start) ||
|
||||
this.isDisabled(this.beaconPairs[threadId].end)
|
||||
) {
|
||||
if (!(threadId in this.commentsState.disabledEditorThreads)) {
|
||||
this.commentsState.disabledEditorThreads[threadId] = editorThread;
|
||||
}
|
||||
if (threadId in this.commentsState.editorThreads) {
|
||||
delete this.commentsState.editorThreads[threadId];
|
||||
}
|
||||
this.cleanupBeaconPair(threadId);
|
||||
} else {
|
||||
if (!(threadId in this.commentsState.editorThreads)) {
|
||||
this.commentsState.editorThreads[threadId] = editorThread;
|
||||
}
|
||||
if (threadId in this.commentsState.disabledEditorThreads) {
|
||||
delete this.commentsState.disabledEditorThreads[threadId];
|
||||
}
|
||||
threadIdsToSort.push(threadId);
|
||||
}
|
||||
}
|
||||
this.sortedThreadIds = threadIdsToSort.sort((a, b) => {
|
||||
return compareDOMPosition(this.beaconPairs[a].start, this.beaconPairs[b].start);
|
||||
});
|
||||
}
|
||||
|
||||
drawThreadOverlays() {
|
||||
const overlayRect = this.overlayContainer.getBoundingClientRect();
|
||||
for (const threadId of this.sortedThreadIds) {
|
||||
this.cleanupBeaconPair(threadId);
|
||||
const beaconPair = this.beaconPairs[threadId];
|
||||
if (!beaconPair.start.isConnected || !beaconPair.end.isConnected) {
|
||||
continue;
|
||||
}
|
||||
const range = new Range();
|
||||
range.setStart(...rightPos(beaconPair.start));
|
||||
range.setEnd(...leftPos(beaconPair.end));
|
||||
const clientRects = Array.from(range.getClientRects());
|
||||
if (!clientRects.length) {
|
||||
continue;
|
||||
}
|
||||
this.commentsState.editorThreads[threadId].top = clientRects[0].y - overlayRect.y;
|
||||
clientRects.reverse();
|
||||
const identifyRect = (big, small) => {
|
||||
if (big.width === 0 || big.height === 0) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
small &&
|
||||
Math.floor(big.x) <= small.x &&
|
||||
Math.floor(big.y) <= small.y &&
|
||||
Math.ceil(big.x + big.width) >= small.x + small.width &&
|
||||
Math.ceil(big.y + big.height) >= small.y + small.height
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Faster than elementsFromPoint, but some rects will be omitted
|
||||
// if they are under another element like the editor toolbar.
|
||||
const target = this.document.elementFromPoint(big.x + big.width / 2, big.y);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
let valid = true;
|
||||
let closestEditable = target.closest("[data-embedded-editable]");
|
||||
if (!this.source.contains(closestEditable)) {
|
||||
closestEditable = undefined;
|
||||
}
|
||||
let embedded = target.closest("[data-embedded]");
|
||||
if (!this.source.contains(embedded)) {
|
||||
embedded = undefined;
|
||||
}
|
||||
if (embedded && (!closestEditable || !embedded.contains(closestEditable))) {
|
||||
valid = false;
|
||||
}
|
||||
if (!valid || (target.textContent === "" && target.nodeName !== "IMG")) {
|
||||
return;
|
||||
}
|
||||
return target;
|
||||
};
|
||||
let previousRect;
|
||||
const indicators = [];
|
||||
for (const rect of clientRects) {
|
||||
const identity = identifyRect(rect, previousRect);
|
||||
if (rect.width && rect.height) {
|
||||
previousRect = rect;
|
||||
}
|
||||
if (!identity || !this.source.contains(identity)) {
|
||||
continue;
|
||||
}
|
||||
let rectElement;
|
||||
let onFocus;
|
||||
let onActivate;
|
||||
switch (identity.nodeName) {
|
||||
case "IMG":
|
||||
rectElement = this.createImgOverlay(
|
||||
rect,
|
||||
overlayRect,
|
||||
threadId,
|
||||
getComputedStyle(identity)
|
||||
);
|
||||
onFocus = () => {
|
||||
const style = getComputedStyle(identity);
|
||||
rectElement.style.setProperty("border-radius", style.borderRadius);
|
||||
rectElement.style.setProperty(
|
||||
"box-shadow",
|
||||
`0 0 0 8px ${this.getThreadOverlayColor(
|
||||
this.commentsState.hasFocus(threadId)
|
||||
)}`
|
||||
);
|
||||
};
|
||||
onActivate = onFocus;
|
||||
break;
|
||||
default:
|
||||
rectElement = this.createTextOverlay(rect, overlayRect, threadId);
|
||||
onFocus = () => {
|
||||
rectElement.style.setProperty(
|
||||
"background-color",
|
||||
this.getThreadOverlayColor(this.commentsState.hasFocus(threadId))
|
||||
);
|
||||
};
|
||||
onActivate = onFocus;
|
||||
break;
|
||||
}
|
||||
if (rectElement) {
|
||||
this.setupOverlayEvents({
|
||||
rectElement,
|
||||
threadId,
|
||||
onFocus,
|
||||
onActivate,
|
||||
});
|
||||
indicators.push(rectElement);
|
||||
}
|
||||
}
|
||||
this.overlayContainer.append(...indicators);
|
||||
}
|
||||
}
|
||||
|
||||
createTextOverlay(rect, overlayRect, threadId) {
|
||||
const { x, y, width, height } = rect;
|
||||
const rectElement = this.document.createElement("div");
|
||||
rectElement.style = `
|
||||
position: absolute;
|
||||
top: ${y - overlayRect.y}px;
|
||||
left: ${x - overlayRect.x}px;
|
||||
width: ${width}px;
|
||||
height: ${height}px;
|
||||
pointer-events: ${this.readonly ? "auto" : "none"};
|
||||
cursor: ${this.readonly ? "grab" : "auto"};
|
||||
background-color: ${this.getThreadOverlayColor(this.commentsState.hasFocus(threadId))};
|
||||
opacity: 0.5;
|
||||
`;
|
||||
rectElement.dataset.threadId = threadId;
|
||||
return rectElement;
|
||||
}
|
||||
|
||||
createImgOverlay(rect, overlayRect, threadId, style) {
|
||||
const { x, y, width, height } = rect;
|
||||
const rectElement = this.document.createElement("div");
|
||||
rectElement.style = `
|
||||
position: absolute;
|
||||
top: ${y - overlayRect.y}px;
|
||||
left: ${x - overlayRect.x}px;
|
||||
width: ${width}px;
|
||||
height: ${height}px;
|
||||
pointer-events: ${this.readonly ? "auto" : "none"};
|
||||
cursor: ${this.readonly ? "grab" : "auto"};
|
||||
box-shadow: 0 0 0 8px ${this.getThreadOverlayColor(
|
||||
this.commentsState.hasFocus(threadId)
|
||||
)};
|
||||
border-radius: ${style.borderRadius};
|
||||
opacity: 0.5;
|
||||
`;
|
||||
rectElement.dataset.threadId = threadId;
|
||||
return rectElement;
|
||||
}
|
||||
|
||||
setupOverlayEvents({ rectElement, threadId, onFocus, onActivate }) {
|
||||
this.cleanups[threadId] ||= new Set();
|
||||
const thread = this.commentsState.editorThreads[threadId];
|
||||
if (onActivate) {
|
||||
thread.onActivateMap.set(rectElement, onActivate);
|
||||
}
|
||||
if (onFocus) {
|
||||
thread.onFocusMap.set(rectElement, onFocus);
|
||||
}
|
||||
const onReadonlyActivate = (ev) => {
|
||||
thread.onActivate(ev);
|
||||
};
|
||||
const onReadonlyFocus = (ev) => {
|
||||
thread.onFocus(ev);
|
||||
};
|
||||
if (this.readonly) {
|
||||
rectElement.addEventListener("click", onReadonlyActivate);
|
||||
rectElement.addEventListener("mouseenter", onReadonlyFocus);
|
||||
rectElement.addEventListener("mouseleave", onReadonlyFocus);
|
||||
}
|
||||
this.cleanups[threadId].add(() => {
|
||||
if (this.readonly) {
|
||||
rectElement.removeEventListener("click", onReadonlyActivate);
|
||||
rectElement.removeEventListener("mouseenter", onReadonlyFocus);
|
||||
rectElement.removeEventListener("mouseleave", onReadonlyFocus);
|
||||
}
|
||||
thread.onActivateMap.delete(rectElement);
|
||||
thread.onFocusMap.delete(rectElement);
|
||||
rectElement.remove();
|
||||
});
|
||||
}
|
||||
|
||||
getThreadOverlayColor(focus) {
|
||||
return `rgba(27, 161, 228, ${focus ? "0.75" : "0.25"})`;
|
||||
}
|
||||
|
||||
computeTextMap(beaconPair) {
|
||||
const range = new Range();
|
||||
range.setStart(...rightPos(beaconPair.start));
|
||||
range.setEnd(...leftPos(beaconPair.end));
|
||||
const fragment = range.cloneContents();
|
||||
const embeds = [...fragment.querySelectorAll("[data-embedded]")].reverse();
|
||||
for (const embed of embeds) {
|
||||
embed.replaceWith(...embed.querySelectorAll("[data-embedded-editable]"));
|
||||
}
|
||||
return childNodes(fragment).map((node) => {
|
||||
if (node.nodeName === "IMG") {
|
||||
return node.src;
|
||||
}
|
||||
return node.textContent.trim();
|
||||
});
|
||||
}
|
||||
|
||||
validate(beaconPair) {
|
||||
// is in DOM
|
||||
if (!beaconPair.start.isConnected || !beaconPair.end.isConnected) {
|
||||
return false;
|
||||
}
|
||||
// start is before end
|
||||
if (compareDOMPosition(beaconPair.start, beaconPair.end) !== -1) {
|
||||
return false;
|
||||
}
|
||||
// is related to the correct article
|
||||
if (
|
||||
parseInt(beaconPair.start.dataset.res_id) !== this.commentsState.articleId ||
|
||||
parseInt(beaconPair.end.dataset.res_id) !== this.commentsState.articleId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// is deleted
|
||||
if (this.commentsState.deletedThreadIds.has(beaconPair.start.dataset.id)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
this.commentsState.activeThreadId !== "undefined" &&
|
||||
beaconPair.start.dataset.id === "undefined" &&
|
||||
(!this.pendingBeacons.has(beaconPair.start) || !this.pendingBeacons.has(beaconPair.end))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// contains no visible text
|
||||
if (this.computeTextMap(beaconPair).join("").trim() === "") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
removeDuplicate(beacon) {
|
||||
// while element at position search compared
|
||||
let index = this.searchBeaconIndexByThreadId(beacon, this.beaconsByThreadId);
|
||||
let currentBeacon = this.beaconsByThreadId.at(index);
|
||||
while (currentBeacon && !compareBeaconsThreadIds(beacon, currentBeacon)) {
|
||||
this.beaconsByThreadId.splice(index, 1);
|
||||
if (beacon !== currentBeacon) {
|
||||
this.bogusBeacons.add(currentBeacon);
|
||||
this.deleteBeacons([currentBeacon]);
|
||||
}
|
||||
index = this.searchBeaconIndexByThreadId(beacon, this.beaconsByThreadId);
|
||||
currentBeacon = this.beaconsByThreadId.at(index);
|
||||
}
|
||||
}
|
||||
|
||||
preserveExistingBeaconPair(beaconPair) {
|
||||
// issue: should remove duplicates even if there is no concurrentBeaconPair
|
||||
// already exists and is valid elsewhere => keep existing
|
||||
const concurrentBeaconPair = this.beaconPairs[beaconPair.start.dataset.id];
|
||||
if (
|
||||
concurrentBeaconPair &&
|
||||
beaconPair !== concurrentBeaconPair &&
|
||||
this.validate(concurrentBeaconPair) &&
|
||||
compareDOMPosition(concurrentBeaconPair.start, concurrentBeaconPair.end) === -1
|
||||
) {
|
||||
if (beaconPair.start !== concurrentBeaconPair.start) {
|
||||
beaconPair.start = concurrentBeaconPair.start;
|
||||
}
|
||||
if (beaconPair.end !== concurrentBeaconPair.end) {
|
||||
beaconPair.end = concurrentBeaconPair.end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activateRelatedThread(target) {
|
||||
this.sortThreads();
|
||||
const index = this.searchBeaconIndex(target, this.beacons);
|
||||
const ends = {};
|
||||
let threadId;
|
||||
for (let i = index - 1; i >= 0; i--) {
|
||||
const beacon = this.beacons[i];
|
||||
if (beacon.dataset.oeType === "threadBeaconEnd") {
|
||||
ends[beacon.dataset.id] = {
|
||||
end: beacon,
|
||||
};
|
||||
} else if (
|
||||
beacon.dataset.id in ends ||
|
||||
!(beacon.dataset.id in this.beaconPairs) ||
|
||||
!this.beaconPairs[beacon.dataset.id].end.isConnected
|
||||
) {
|
||||
continue;
|
||||
} else if (beacon.dataset.id in this.commentsState.editorThreads) {
|
||||
threadId = beacon.dataset.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.commentsState.activeThreadId = threadId;
|
||||
this.drawThreadOverlays();
|
||||
}
|
||||
|
||||
isDisabled(beacon) {
|
||||
return beacon.classList.contains("oe_disabled_thread_beacon");
|
||||
}
|
||||
|
||||
enable(beacon) {
|
||||
beacon.classList.remove("oe_disabled_thread_beacon");
|
||||
}
|
||||
|
||||
disable(beacon) {
|
||||
beacon.classList.add("oe_disabled_thread_beacon");
|
||||
}
|
||||
|
||||
removeBogusBeacons() {
|
||||
for (const beacon of this.bogusBeacons) {
|
||||
// TODO ABD: evaluate cleanupThread ?
|
||||
this.cleanupBeaconPair(beacon.dataset.id);
|
||||
this.removeBeacon(beacon);
|
||||
}
|
||||
this.bogusBeacons = new Set();
|
||||
}
|
||||
|
||||
cleanupBeaconPair(threadId) {
|
||||
for (const cleanup of this.cleanups[threadId] || []) {
|
||||
cleanup();
|
||||
}
|
||||
delete this.cleanups[threadId];
|
||||
}
|
||||
|
||||
destroy() {
|
||||
for (const threadId of Object.keys(this.cleanups)) {
|
||||
this.cleanupThread(threadId);
|
||||
}
|
||||
for (const threadId of Object.keys(this.commentsState.editorThreads)) {
|
||||
delete this.commentsState.editorThreads[threadId];
|
||||
}
|
||||
for (const threadId of Object.keys(this.commentsState.disabledEditorThreads)) {
|
||||
delete this.commentsState.disabledEditorThreads[threadId];
|
||||
}
|
||||
}
|
||||
|
||||
cleanupThread(threadId) {
|
||||
this.cleanupBeaconPair(threadId);
|
||||
delete this.beaconPairs[threadId];
|
||||
delete this.commentsState.editorThreads[threadId];
|
||||
delete this.commentsState.disabledEditorThreads[threadId];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
import { MIN_THREAD_WIDTH, KnowledgeCommentsThread } from "../comment/comment";
|
||||
import { useCallbackRecorder } from "@web/search/action_hook";
|
||||
import { batched, Component, reactive, useEffect, useState, useSubEnv } from "@odoo/owl";
|
||||
import { CommentBeaconManager } from "../../comments/comment_beacon_manager";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { debounce } from "@web/core/utils/timing";
|
||||
import { localization } from "@web/core/l10n/localization";
|
||||
|
||||
const MAX_THREAD_WIDTH = 400;
|
||||
const SMALL_THREAD_WIDTH = 40; // o_knowledge_small_ui
|
||||
|
||||
export class KnowledgeCommentsHandler extends Component {
|
||||
static template = "knowledge.KnowledgeCommentsHandler";
|
||||
static components = { KnowledgeCommentsThread };
|
||||
static props = {
|
||||
commentBeaconManager: { type: CommentBeaconManager },
|
||||
contentRef: { type: Object },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.commentsService = useService("knowledge.comments");
|
||||
this.commentsState = useState(this.commentsService.getCommentsState());
|
||||
const debouncedThreadDimensions = debounce(() => {
|
||||
this.computeThreadDimensions();
|
||||
}, 300);
|
||||
const batchedComputeVerticalDimensions = batched(this.computeVerticalDimensions.bind(this));
|
||||
useSubEnv({
|
||||
threadHeights: reactive({}, batchedComputeVerticalDimensions),
|
||||
});
|
||||
this.lastActiveThreadId;
|
||||
this.state = useState({
|
||||
threadDimensions: {
|
||||
horizontal: {},
|
||||
threadTops: {},
|
||||
},
|
||||
});
|
||||
useCallbackRecorder(this.env.__onLayoutGeometryChange__, debouncedThreadDimensions);
|
||||
let activeThreadId;
|
||||
useEffect(
|
||||
() => {
|
||||
if (this.commentsState.activeThreadId !== activeThreadId) {
|
||||
activeThreadId = this.commentsState.activeThreadId;
|
||||
batchedComputeVerticalDimensions();
|
||||
}
|
||||
},
|
||||
() => [this.commentsState.activeThreadId]
|
||||
);
|
||||
useEffect(
|
||||
() => {
|
||||
const editorThreads = Object.keys(this.commentsState.editorThreads);
|
||||
if (
|
||||
editorThreads.some((threadId) => {
|
||||
return this.props.commentBeaconManager.sortedThreadIds.includes(threadId);
|
||||
})
|
||||
) {
|
||||
debouncedThreadDimensions();
|
||||
}
|
||||
},
|
||||
() => [
|
||||
this.commentsState.editorThreads,
|
||||
Object.keys(this.commentsState.editorThreads).toString(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
get mainPositionThreadId() {
|
||||
if (
|
||||
this.commentsState.activeThreadId &&
|
||||
this.commentsState.activeThreadId !== this.lastActiveThreadId
|
||||
) {
|
||||
this.lastActiveThreadId = this.commentsState.activeThreadId;
|
||||
} else if (
|
||||
!this.props.commentBeaconManager.sortedThreadIds.includes(this.lastActiveThreadId)
|
||||
) {
|
||||
this.lastActiveThreadId = undefined;
|
||||
}
|
||||
return this.lastActiveThreadId || this.props.commentBeaconManager.sortedThreadIds.at(0);
|
||||
}
|
||||
|
||||
computeHorizontalDimensions() {
|
||||
if (!this.props.contentRef.el) {
|
||||
return;
|
||||
}
|
||||
const rtl = localization.direction === "rtl";
|
||||
const keys = {
|
||||
paddingRight: "paddingRight",
|
||||
marginRight: "marginRight",
|
||||
marginLeft: "marginLeft",
|
||||
};
|
||||
if (rtl) {
|
||||
Object.assign(keys, {
|
||||
paddingRight: "paddingLeft",
|
||||
marginRight: "marginLeft",
|
||||
marginLeft: "marginRight",
|
||||
});
|
||||
}
|
||||
const contentStyle = getComputedStyle(this.props.contentRef.el);
|
||||
const paddingRight = parseInt(contentStyle[keys.paddingRight]) || 0;
|
||||
const marginRight = parseInt(contentStyle[keys.marginRight]) || 0;
|
||||
const marginLeft = parseInt(contentStyle[keys.marginLeft]) || 0;
|
||||
const contentRect = this.props.contentRef.el.getBoundingClientRect();
|
||||
const availableWidth = Math.max(0, Math.floor(marginRight + paddingRight));
|
||||
let width = Math.min(MAX_THREAD_WIDTH, Math.max(0, availableWidth - 20));
|
||||
if (!width) {
|
||||
return;
|
||||
}
|
||||
if (width < MIN_THREAD_WIDTH) {
|
||||
width = SMALL_THREAD_WIDTH;
|
||||
}
|
||||
const left =
|
||||
(rtl ? -1 : 1) *
|
||||
Math.ceil(
|
||||
marginLeft +
|
||||
contentRect.width -
|
||||
paddingRight +
|
||||
(availableWidth + (rtl ? 1 : -1) * width) / 2
|
||||
);
|
||||
this.state.threadDimensions.horizontal = { left, width };
|
||||
}
|
||||
|
||||
computeVerticalDimensions() {
|
||||
const activeId = this.mainPositionThreadId;
|
||||
if (!activeId || !this.commentsState.editorThreads[activeId]?.top) {
|
||||
return;
|
||||
}
|
||||
const threadIds = this.props.commentBeaconManager.sortedThreadIds.filter((threadId) => {
|
||||
return (
|
||||
threadId in this.commentsState.editorThreads &&
|
||||
this.commentsState.editorThreads[threadId].top
|
||||
);
|
||||
});
|
||||
const index = threadIds.indexOf(activeId);
|
||||
this.setThreadTop(activeId, this.commentsState.editorThreads[activeId].top);
|
||||
let masterTop = this.getThreadTop(activeId);
|
||||
for (let i = index - 1; i >= 0; i--) {
|
||||
const threadId = threadIds[i];
|
||||
const expectedTop = this.commentsState.editorThreads[threadId].top;
|
||||
const height = this.env.threadHeights[threadId]?.height || 0;
|
||||
if (expectedTop + height < masterTop) {
|
||||
masterTop = expectedTop;
|
||||
} else {
|
||||
masterTop -= height;
|
||||
}
|
||||
this.setThreadTop(threadId, masterTop);
|
||||
}
|
||||
masterTop = this.getThreadTop(activeId) + (this.env.threadHeights[activeId]?.height || 0);
|
||||
for (let i = index + 1; i < threadIds.length; i++) {
|
||||
const threadId = threadIds[i];
|
||||
const expectedTop = this.commentsState.editorThreads[threadId].top;
|
||||
masterTop = Math.max(masterTop, expectedTop);
|
||||
this.setThreadTop(threadId, masterTop);
|
||||
masterTop += this.env.threadHeights[threadId]?.height || 0;
|
||||
}
|
||||
}
|
||||
|
||||
computeThreadDimensions() {
|
||||
this.computeHorizontalDimensions();
|
||||
this.computeVerticalDimensions();
|
||||
}
|
||||
|
||||
setThreadTop(threadId, top) {
|
||||
if (!(threadId in this.state.threadDimensions.threadTops)) {
|
||||
this.state.threadDimensions.threadTops[threadId] = {
|
||||
top: undefined,
|
||||
};
|
||||
}
|
||||
this.state.threadDimensions.threadTops[threadId].top = top;
|
||||
}
|
||||
|
||||
getThreadTop(threadId) {
|
||||
return this.state.threadDimensions.threadTops[threadId]?.top;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<div t-name="knowledge.KnowledgeCommentsHandler" t-ref="rootCommentsRef"
|
||||
class="o_knowledge_comments_handler position-absolute top-0 start-100 d-print-none">
|
||||
<t t-if="commentsState.displayMode === 'handler'">
|
||||
<t t-foreach="props.commentBeaconManager.sortedThreadIds" t-as="threadId" t-key="threadId">
|
||||
<KnowledgeCommentsThread threadId="threadId" top="getThreadTop(threadId)"
|
||||
horizontalDimensions="state.threadDimensions.horizontal"/>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
import { registry } from "@web/core/registry";
|
||||
import { standardWidgetProps } from "@web/views/widgets/standard_widget_props";
|
||||
import { user } from "@web/core/user";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { KnowledgeCommentsThread } from "../comment/comment";
|
||||
import { Component, onWillDestroy, onWillStart, useEffect, useRef, useState } from "@odoo/owl";
|
||||
import { batched, debounce } from "@web/core/utils/timing";
|
||||
import { effect } from "@web/core/utils/reactive";
|
||||
import { LOAD_THREADS_LIMIT } from "../../comments/comments_service";
|
||||
|
||||
export class KnowledgeCommentsPanel extends Component {
|
||||
static template = "knowledge.KnowledgeCommentsPanel";
|
||||
static components = { KnowledgeCommentsThread };
|
||||
static props = { ...standardWidgetProps };
|
||||
|
||||
setup() {
|
||||
this.rootRef = useRef("root");
|
||||
this.commentsService = useService("knowledge.comments");
|
||||
this.commentsState = useState(this.commentsService.getCommentsState());
|
||||
let threadRecordsKeys;
|
||||
this.alive = true;
|
||||
effect(
|
||||
batched((state) => {
|
||||
if (!this.alive) {
|
||||
return;
|
||||
}
|
||||
if (state.displayMode !== "panel") {
|
||||
return;
|
||||
}
|
||||
const threadRecords = state.threadRecords;
|
||||
const threadIds = Object.keys(threadRecords);
|
||||
const keys = threadIds.toString();
|
||||
if (keys !== threadRecordsKeys) {
|
||||
this.computeThreadIds();
|
||||
threadRecordsKeys = keys;
|
||||
}
|
||||
}),
|
||||
[this.commentsState]
|
||||
);
|
||||
onWillDestroy(() => {
|
||||
this.alive = false;
|
||||
});
|
||||
this.state = useState({
|
||||
mode: "unresolved", // "resolved" / "all"
|
||||
loadMoreResolved: false,
|
||||
loadMoreOpen: false,
|
||||
loading: false,
|
||||
threadIds: [],
|
||||
});
|
||||
let firstLoad = true;
|
||||
useEffect(
|
||||
() => {
|
||||
if (this.commentsState.displayMode !== "panel") {
|
||||
return;
|
||||
}
|
||||
this.computeThreadIds();
|
||||
this.commentsService
|
||||
.loadRecords(this.env.model.root.resId, {
|
||||
ignoreBatch: true,
|
||||
includeLoaded: true,
|
||||
domain: this.domain,
|
||||
})
|
||||
.then((count) => {
|
||||
if (firstLoad) {
|
||||
firstLoad = false;
|
||||
if (count !== undefined && count < LOAD_THREADS_LIMIT) {
|
||||
this.sealLoadMoreState();
|
||||
} else {
|
||||
this.state.loadMoreOpen = true;
|
||||
this.state.loadMoreResolved = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
() => [this.state.mode, this.commentsState.articleId, this.commentsState.displayMode]
|
||||
);
|
||||
// TODO ABD: refactor form view style to not have to do this
|
||||
useEffect(
|
||||
() => {
|
||||
if (this.commentsState.displayMode === "panel") {
|
||||
this.rootRef.el?.parentElement.classList.remove("d-none");
|
||||
} else {
|
||||
this.rootRef.el?.parentElement.classList.add("d-none");
|
||||
}
|
||||
},
|
||||
() => [this.commentsState.displayMode]
|
||||
);
|
||||
onWillStart(async () => {
|
||||
// TODO ABD: test this use case
|
||||
if (
|
||||
this.env.services.action.currentController?.action?.context?.show_resolved_threads
|
||||
) {
|
||||
this.commentsState.displayMode = "panel";
|
||||
this.mode = "resolved";
|
||||
this.env.services.action.currentController.action.context.show_resolved_threads = false;
|
||||
}
|
||||
this.isPortalUser = await user.hasGroup("base.group_portal");
|
||||
this.isInternalUser = await user.hasGroup("base.group_user");
|
||||
});
|
||||
const loadMore = debounce(this.loadMore.bind(this), 500);
|
||||
this.loadMore = () => {
|
||||
this.state.loading = true;
|
||||
loadMore();
|
||||
};
|
||||
}
|
||||
|
||||
canDisplayRecord(threadId) {
|
||||
if (!this.commentsState.threadRecords[threadId]) {
|
||||
return true;
|
||||
}
|
||||
if (this.state.mode === "unresolved") {
|
||||
return !this.commentsState.threadRecords[threadId].is_resolved;
|
||||
} else if (this.state.mode === "resolved") {
|
||||
return this.commentsState.threadRecords[threadId].is_resolved;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
get couldLoadMore() {
|
||||
if (this.state.mode === "unresolved") {
|
||||
return this.state.loadMoreOpen;
|
||||
} else if (this.state.mode === "resolved") {
|
||||
return this.state.loadMoreResolved;
|
||||
} else {
|
||||
return this.state.loadMoreOpen || this.state.loadMoreResolved;
|
||||
}
|
||||
}
|
||||
|
||||
get domain() {
|
||||
let domain = undefined;
|
||||
if (this.state.mode === "unresolved") {
|
||||
domain = [["is_resolved", "=", false]];
|
||||
} else if (this.state.mode === "resolved") {
|
||||
domain = [["is_resolved", "=", true]];
|
||||
}
|
||||
return domain;
|
||||
}
|
||||
|
||||
computeThreadIds() {
|
||||
const threadIds = [];
|
||||
for (const [threadId, record] of Object.entries(this.commentsState.threadRecords)) {
|
||||
if (
|
||||
this.state.mode === "all" ||
|
||||
(this.state.mode === "resolved" && record.is_resolved) ||
|
||||
(this.state.mode === "unresolved" && !record.is_resolved)
|
||||
) {
|
||||
threadIds.push(threadId);
|
||||
}
|
||||
}
|
||||
this.state.threadIds = threadIds.sort((threadIdA, threadIdB) => {
|
||||
const dateA = this.commentsState.threadRecords[threadIdA].write_date;
|
||||
const dateB = this.commentsState.threadRecords[threadIdB].write_date;
|
||||
if (dateA < dateB) {
|
||||
return 1;
|
||||
} else if (dateA > dateB) {
|
||||
return -1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onChangeMode(ev) {
|
||||
this.state.mode = ev.target.value;
|
||||
}
|
||||
|
||||
async loadMore() {
|
||||
const count = await this.commentsService.loadRecords(this.env.model.root.resId, {
|
||||
ignoreBatch: true,
|
||||
domain: this.domain,
|
||||
});
|
||||
if (count !== undefined && count < LOAD_THREADS_LIMIT) {
|
||||
this.sealLoadMoreState();
|
||||
}
|
||||
this.state.loading = false;
|
||||
}
|
||||
|
||||
sealLoadMoreState() {
|
||||
if (this.state.mode === "unresolved") {
|
||||
this.state.loadMoreOpen = false;
|
||||
} else if (this.state.mode === "resolved") {
|
||||
this.state.loadMoreResolved = false;
|
||||
} else {
|
||||
this.state.loadMoreResolved = false;
|
||||
this.state.loadMoreOpen = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const knowledgeCommentsPanel = {
|
||||
component: KnowledgeCommentsPanel,
|
||||
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,33 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<div t-name="knowledge.KnowledgeCommentsPanel" t-ref="root">
|
||||
<div t-if="commentsState.displayMode === 'panel'" class="o_knowledge_comments_panel">
|
||||
<select t-att-value="state.mode" t-on-change="onChangeMode" class="form-select border-0 my-2">
|
||||
<option value="all">All Discussions</option>
|
||||
<option value="resolved">Resolved Discussions</option>
|
||||
<option value="unresolved">Open Discussions</option>
|
||||
</select>
|
||||
<t t-foreach="state.threadIds" t-as="threadId" t-key="threadId">
|
||||
<div t-if="canDisplayRecord(threadId)" class="card ps-0 mb-2" t-att-class="{
|
||||
'o_knowledge_comment_card_borderless': !commentsState.threads[threadId],
|
||||
}">
|
||||
<div class="card-body p-0 m-0 row">
|
||||
<KnowledgeCommentsThread threadId="threadId"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<div t-if="couldLoadMore" class="d-flex flex-column align-items-center mb-2">
|
||||
<button class="btn btn-sm btn-link" t-on-click="loadMore" t-att-disabled="this.state.loading">
|
||||
Load More Discussions
|
||||
</button>
|
||||
</div>
|
||||
<div t-if="!state.threadIds.length" 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,34 @@
|
|||
import { Composer } from "@mail/core/common/composer";
|
||||
import { KnowledgeCommentCreatorComposer } from "../../mail/composer/composer";
|
||||
import { KnowledgeThread } from "@knowledge/mail/thread/knowledge_thread";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { Component, useRef, useState } from "@odoo/owl";
|
||||
|
||||
export class KnowledgeCommentsPopover extends Component {
|
||||
static template = "knowledge.KnowledgeCommentsPopover";
|
||||
static props = {
|
||||
threadId: { type: String },
|
||||
close: { type: Function },
|
||||
};
|
||||
static components = { Composer, KnowledgeThread, KnowledgeCommentCreatorComposer };
|
||||
|
||||
setup() {
|
||||
this.commentsService = useService("knowledge.comments");
|
||||
this.commentsState = useState(this.commentsService.getCommentsState());
|
||||
this.rootRef = useRef("rootRef");
|
||||
}
|
||||
|
||||
onPostCallback() {
|
||||
this.props.close();
|
||||
}
|
||||
|
||||
onCreateThreadCallback(thread) {
|
||||
if (thread) {
|
||||
this.commentsState.editorThreads[thread.id]?.select();
|
||||
}
|
||||
}
|
||||
|
||||
get thread() {
|
||||
return this.commentsState.threads[this.props.threadId];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="knowledge.KnowledgeCommentsPopover">
|
||||
<div t-ref="rootRef" class="o_knowledge_comment_scrollable o_knowledge_comment_box">
|
||||
<t t-if="props.threadId !== 'undefined'">
|
||||
<KnowledgeThread
|
||||
thread="thread"
|
||||
order="'asc'"
|
||||
showDates="false"
|
||||
showEmptyMessage="false"
|
||||
showJumpPresent="false"
|
||||
scrollRef="rootRef"
|
||||
/>
|
||||
<div class="o_knowledge_sticky_composer rounded px-2">
|
||||
<Composer
|
||||
composer="thread.composer" mode="'extended'" type="'note'"
|
||||
showFullComposer="false" placeholder.translate="Add a comment..."
|
||||
/>
|
||||
</div>
|
||||
</t>
|
||||
<div t-else="" class="pt-2 px-2">
|
||||
<KnowledgeCommentCreatorComposer placeholder.translate="Add a comment..."
|
||||
composer="thread.composer" type="'note'" showFullComposer="false" autofocus="true"
|
||||
mode="'extended'" allowUpload="false" onPostCallback.bind="onPostCallback"
|
||||
onCreateThreadCallback.bind="onCreateThreadCallback"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,357 @@
|
|||
import { registry } from "@web/core/registry";
|
||||
import { batched, reactive } from "@odoo/owl";
|
||||
import { Deferred } from "@web/core/utils/concurrency";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { uuid } from "@web/views/utils";
|
||||
import { effect } from "@web/core/utils/reactive";
|
||||
|
||||
const ARTICLE_THREAD_FIELDS = [
|
||||
"id",
|
||||
"article_id",
|
||||
"article_anchor_text",
|
||||
"is_resolved",
|
||||
"write_date",
|
||||
];
|
||||
|
||||
export const LOAD_THREADS_LIMIT = 30;
|
||||
|
||||
function clearThreadComposer(composer) {
|
||||
if (!composer) {
|
||||
return;
|
||||
}
|
||||
composer.clear();
|
||||
browser.localStorage.removeItem(composer.localId);
|
||||
}
|
||||
|
||||
export const knowledgeCommentsService = {
|
||||
dependencies: ["orm", "mail.store"],
|
||||
start(env, services) {
|
||||
this.services = services;
|
||||
this.commentsState = reactive({
|
||||
articleId: undefined,
|
||||
activeThreadId: undefined,
|
||||
// database records
|
||||
threadRecords: {},
|
||||
// mail.store instances
|
||||
threads: {},
|
||||
disabledEditorThreads: {},
|
||||
// editor metadata
|
||||
editorThreads: {},
|
||||
displayMode: "handler", // 'handler' 'panel'
|
||||
focusedThreads: new Set(),
|
||||
deletedThreadIds: new Set(),
|
||||
hasFocus(threadId) {
|
||||
return this.focusedThreads.has(threadId) || this.activeThreadId === threadId;
|
||||
},
|
||||
});
|
||||
let previousArticleId;
|
||||
effect(
|
||||
(state) => {
|
||||
if (previousArticleId !== state.articleId) {
|
||||
this.resetForArticleId();
|
||||
previousArticleId = state.articleId;
|
||||
}
|
||||
},
|
||||
[this.commentsState]
|
||||
);
|
||||
return {
|
||||
createThread: this.createThread.bind(this),
|
||||
createVirtualThread: this.createVirtualThread.bind(this),
|
||||
deleteThread: this.deleteThread.bind(this),
|
||||
fetchMessages: this.fetchMessages.bind(this),
|
||||
getCommentsState: this.getCommentsState.bind(this),
|
||||
loadRecords: this.loadRecords.bind(this),
|
||||
loadThreads: this.loadThreads.bind(this),
|
||||
setArticleId: this.setArticleId.bind(this),
|
||||
updateResolveState: this.updateResolveState.bind(this),
|
||||
};
|
||||
},
|
||||
async createThread(value, postData) {
|
||||
if (!value || !("undefined" in this.commentsState.editorThreads)) {
|
||||
return;
|
||||
}
|
||||
const loadingId = this.loadingId;
|
||||
const record = await rpc("/knowledge/thread/create", {
|
||||
article_id: this.commentsState.articleId,
|
||||
article_anchor_text: this.commentsState.editorThreads["undefined"].anchorText,
|
||||
fields: ARTICLE_THREAD_FIELDS,
|
||||
});
|
||||
if (loadingId !== this.loadingId) {
|
||||
return;
|
||||
}
|
||||
this.commentsState.threadRecords[record.id] = record;
|
||||
this.commentsState.editorThreads[record.id] = this.commentsState.editorThreads["undefined"];
|
||||
delete this.commentsState.editorThreads["undefined"];
|
||||
this.commentsState.editorThreads[record.id].setThreadId(record.id);
|
||||
clearThreadComposer(this.commentsState.threads["undefined"].composer);
|
||||
const thread = this.services["mail.store"].Thread.insert({
|
||||
id: record.id,
|
||||
model: "knowledge.article.thread",
|
||||
articleId: this.commentsState.articleId,
|
||||
});
|
||||
this.commentsState.threads[record.id] = thread;
|
||||
thread.post(value, postData);
|
||||
return thread;
|
||||
},
|
||||
createVirtualThread() {
|
||||
this.commentsState.threads["undefined"] = this.services["mail.store"].Thread.insert({
|
||||
id: undefined,
|
||||
model: "knowledge.article.thread",
|
||||
articleId: this.commentsState.articleId,
|
||||
});
|
||||
clearThreadComposer(this.commentsState.threads["undefined"].composer);
|
||||
return this.commentsState.threads["undefined"];
|
||||
},
|
||||
async deleteThread(resId) {
|
||||
if (resId === "undefined") {
|
||||
return;
|
||||
}
|
||||
this.batchedDeleteThread.threadIds.add(resId);
|
||||
this.batchedDeleteThread();
|
||||
},
|
||||
// threadId is a number
|
||||
async fetchMessages(threadId) {
|
||||
const deferred = new Deferred();
|
||||
this.batchedFetchMessages.deferredPromises[threadId] ||= [];
|
||||
this.batchedFetchMessages.deferredPromises[threadId].push(deferred);
|
||||
this.batchedFetchMessages.threadIds.add(threadId);
|
||||
this.batchedFetchMessages();
|
||||
return await deferred;
|
||||
},
|
||||
getCommentsState() {
|
||||
return this.commentsState;
|
||||
},
|
||||
loadRecords(articleId, { domain, ignoreBatch, includeLoaded, limit, threadId } = {}) {
|
||||
if (this.commentsState.articleId !== articleId) {
|
||||
return;
|
||||
}
|
||||
if (ignoreBatch) {
|
||||
return this._loadRecords({
|
||||
domain,
|
||||
limit,
|
||||
includeLoaded,
|
||||
});
|
||||
}
|
||||
if (threadId) {
|
||||
if (threadId === "undefined") {
|
||||
return;
|
||||
}
|
||||
this.batchedLoadRecords.threadIds.add(threadId);
|
||||
this.batchedLoadRecords();
|
||||
}
|
||||
},
|
||||
loadThreads(resIds) {
|
||||
for (const resId of resIds) {
|
||||
this.commentsState.threads[resId] = this.services["mail.store"].Thread.insert({
|
||||
id: resId === "undefined" ? undefined : resId,
|
||||
model: "knowledge.article.thread",
|
||||
articleId: this.commentsState.articleId,
|
||||
});
|
||||
}
|
||||
},
|
||||
makeBatchedDeleteThread() {
|
||||
const deleteThread = async () => {
|
||||
if (this.loadingId !== batch.loadingId) {
|
||||
return;
|
||||
}
|
||||
const resIds = [];
|
||||
for (const resId of batch.threadIds) {
|
||||
resIds.push(parseInt(resId));
|
||||
}
|
||||
batch.threadIds = new Set();
|
||||
try {
|
||||
await this.services["orm"].unlink("knowledge.article.thread", resIds);
|
||||
} catch {
|
||||
// deleted threads may already be deleted, or the current user
|
||||
// may not have the right to delete them.
|
||||
}
|
||||
if (this.loadingId !== batch.loadingId) {
|
||||
return;
|
||||
}
|
||||
for (const resId of resIds) {
|
||||
delete this.commentsState.threadRecords[resId];
|
||||
delete this.commentsState.threads[resId];
|
||||
this.commentsState.deletedThreadIds.add(resId.toString());
|
||||
this.commentsState.editorThreads[resId]?.removeBeacons();
|
||||
}
|
||||
};
|
||||
const batch = batched(deleteThread);
|
||||
batch.threadIds = new Set();
|
||||
batch.loadingId = this.loadingId;
|
||||
return batch;
|
||||
},
|
||||
makeBatchedFetchMessages() {
|
||||
const fetchMessages = async () => {
|
||||
if (this.loadingId !== batch.loadingId) {
|
||||
return;
|
||||
}
|
||||
const deferredPromises = batch.deferredPromises;
|
||||
const thread_ids = Array.from(batch.threadIds);
|
||||
batch.threadIds = new Set();
|
||||
batch.deferredPromises = {};
|
||||
let error;
|
||||
let result;
|
||||
try {
|
||||
result = await rpc("/knowledge/threads/messages", {
|
||||
thread_model: "knowledge.article.thread",
|
||||
thread_ids,
|
||||
});
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
// thread_id is a number, not a string (used for backend)
|
||||
for (const thread_id in deferredPromises) {
|
||||
for (const deferred of deferredPromises[thread_id]) {
|
||||
if (error) {
|
||||
deferred.reject(error);
|
||||
} else {
|
||||
deferred.resolve(result[thread_id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const batch = batched(fetchMessages);
|
||||
batch.deferredPromises = {};
|
||||
batch.threadIds = new Set();
|
||||
batch.loadingId = this.loadingId;
|
||||
return batch;
|
||||
},
|
||||
makeBatchedLoadRecords() {
|
||||
const loadRecords = async () => {
|
||||
if (this.loadingId !== batch.loadingId) {
|
||||
return;
|
||||
}
|
||||
const excludedSet = new Set(Object.keys(this.commentsState.threadRecords));
|
||||
const targetedSet = batch.threadIds;
|
||||
batch.threadIds = new Set();
|
||||
for (const threadId of [...targetedSet]) {
|
||||
if (excludedSet.has(threadId)) {
|
||||
targetedSet.delete(threadId);
|
||||
}
|
||||
}
|
||||
let threadRecords = [];
|
||||
if (targetedSet.size) {
|
||||
const queryDomain = [
|
||||
["article_id", "=", this.commentsState.articleId],
|
||||
["id", "in", [...targetedSet]],
|
||||
];
|
||||
threadRecords = await this.services["orm"].searchRead(
|
||||
"knowledge.article.thread",
|
||||
queryDomain,
|
||||
ARTICLE_THREAD_FIELDS
|
||||
);
|
||||
if (this.loadingId !== batch.loadingId) {
|
||||
return;
|
||||
}
|
||||
for (const threadRecord of threadRecords) {
|
||||
const threadId = threadRecord.id.toString();
|
||||
this.commentsState.threadRecords[threadId] = threadRecord;
|
||||
if (threadRecord.is_resolved) {
|
||||
this.commentsState.editorThreads[threadId]?.disableBeacons();
|
||||
} else {
|
||||
this.commentsState.disabledEditorThreads[threadId]?.enableBeacons();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (targetedSet.size) {
|
||||
// cleanup targetedThreadIds which do not exist anymore
|
||||
for (const threadId of targetedSet) {
|
||||
if (
|
||||
!(threadId in this.commentsState.threadRecords) &&
|
||||
!this.commentsState.editorThreads[threadId]?.isProtected()
|
||||
) {
|
||||
this.batchedDeleteThread.threadIds.add(threadId);
|
||||
}
|
||||
}
|
||||
if (this.batchedDeleteThread.threadIds.size) {
|
||||
this.batchedDeleteThread();
|
||||
}
|
||||
}
|
||||
};
|
||||
const batch = batched(loadRecords);
|
||||
batch.loadingId = this.loadingId;
|
||||
batch.threadIds = new Set();
|
||||
return batch;
|
||||
},
|
||||
makeLoadRecords() {
|
||||
const loadRecords = async ({ domain, limit, includeLoaded } = {}) => {
|
||||
if (!limit) {
|
||||
limit = LOAD_THREADS_LIMIT;
|
||||
}
|
||||
const options = {
|
||||
limit,
|
||||
};
|
||||
const queryDomain = [["article_id", "=", this.commentsState.articleId]];
|
||||
if (domain) {
|
||||
queryDomain.push(...domain);
|
||||
}
|
||||
if (!includeLoaded) {
|
||||
const excludedThreadIds = Object.keys(this.commentsState.threadRecords);
|
||||
queryDomain.push(["id", "not in", excludedThreadIds]);
|
||||
}
|
||||
const threadRecords = await this.services["orm"].searchRead(
|
||||
"knowledge.article.thread",
|
||||
queryDomain,
|
||||
ARTICLE_THREAD_FIELDS,
|
||||
options
|
||||
);
|
||||
if (this.loadingId !== loadRecords.loadingId) {
|
||||
return;
|
||||
}
|
||||
for (const threadRecord of threadRecords) {
|
||||
const threadId = threadRecord.id.toString();
|
||||
this.commentsState.threadRecords[threadId] = threadRecord;
|
||||
if (threadRecord.is_resolved) {
|
||||
this.commentsState.editorThreads[threadId]?.disableBeacons();
|
||||
} else {
|
||||
this.commentsState.disabledEditorThreads[threadId]?.enableBeacons();
|
||||
}
|
||||
}
|
||||
return threadRecords.length;
|
||||
};
|
||||
loadRecords.loadingId = this.loadingId;
|
||||
return loadRecords;
|
||||
},
|
||||
resetForArticleId() {
|
||||
this.loadingId = uuid();
|
||||
this.commentsState.activeThreadId = undefined;
|
||||
this.commentsState.focusedThreads = new Set();
|
||||
this.commentsState.threadRecords = {};
|
||||
this.commentsState.threads = {};
|
||||
this.commentsState.disabledEditorThreads = {};
|
||||
this.commentsState.editorThreads = {};
|
||||
this.batchedFetchMessages = this.makeBatchedFetchMessages();
|
||||
this.batchedDeleteThread = this.makeBatchedDeleteThread();
|
||||
this.batchedLoadRecords = this.makeBatchedLoadRecords();
|
||||
this._loadRecords = this.makeLoadRecords();
|
||||
},
|
||||
setArticleId(articleId) {
|
||||
if (articleId !== this.commentsState.articleId) {
|
||||
this.commentsState.articleId = articleId;
|
||||
this.resetForArticleId();
|
||||
}
|
||||
},
|
||||
async updateResolveState(resId, resolvedState) {
|
||||
const loadingId = this.loadingId;
|
||||
try {
|
||||
await this.services["orm"].write("knowledge.article.thread", [parseInt(resId)], {
|
||||
is_resolved: resolvedState,
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (this.loadingId !== loadingId) {
|
||||
return false;
|
||||
}
|
||||
this.commentsState.threadRecords[resId].is_resolved = resolvedState;
|
||||
if (resolvedState) {
|
||||
this.commentsState.editorThreads[resId]?.disableBeacons();
|
||||
} else {
|
||||
this.commentsState.disabledEditorThreads[resId]?.enableBeacons();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
registry.category("services").add("knowledge.comments", knowledgeCommentsService);
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
export class EditorThreadInfo {
|
||||
constructor({
|
||||
beaconPair,
|
||||
threadId,
|
||||
computeTextMap = () => [],
|
||||
enableBeaconPair = () => {},
|
||||
disableBeaconPair = () => {},
|
||||
isOwned = () => {},
|
||||
removeBeaconPair = () => {},
|
||||
setSelectionInBeaconPair = () => {},
|
||||
setBeaconPairId = () => {},
|
||||
} = {}) {
|
||||
this.beaconPair = beaconPair;
|
||||
this.threadId = threadId;
|
||||
this.computeTextMap = computeTextMap;
|
||||
this.isOwned = isOwned;
|
||||
this.removeBeaconPair = removeBeaconPair;
|
||||
this.setSelectionInBeaconPair = setSelectionInBeaconPair;
|
||||
this.setBeaconPairId = setBeaconPairId;
|
||||
this.enableBeaconPair = enableBeaconPair;
|
||||
this.disableBeaconPair = disableBeaconPair;
|
||||
this.onActivateMap = new Map();
|
||||
this.onFocusMap = new Map();
|
||||
this.hover = false;
|
||||
this.top = 0;
|
||||
this.anchorText = this.computeCurrentAnchorText();
|
||||
if (this.threadId === "undefined") {
|
||||
this.isNew = true;
|
||||
}
|
||||
}
|
||||
|
||||
computeCurrentAnchorText() {
|
||||
return this.computeTextMap(this.beaconPair).join("\n").trim();
|
||||
}
|
||||
|
||||
setThreadId(id) {
|
||||
this.threadId = id;
|
||||
this.setBeaconPairId(this.beaconPair, id);
|
||||
}
|
||||
|
||||
enableBeacons() {
|
||||
this.enableBeaconPair(this.beaconPair);
|
||||
}
|
||||
|
||||
disableBeacons() {
|
||||
this.disableBeaconPair(this.beaconPair);
|
||||
}
|
||||
|
||||
removeBeacons() {
|
||||
this.removeBeaconPair(this.beaconPair);
|
||||
}
|
||||
|
||||
select() {
|
||||
this.setSelectionInBeaconPair(this.beaconPair);
|
||||
}
|
||||
|
||||
handleEventMapEntries(ev, map) {
|
||||
map.get("main")?.(ev);
|
||||
for (const [key, handler] of map.entries()) {
|
||||
if (key.isConnected) {
|
||||
handler(ev);
|
||||
} else if (key !== "main") {
|
||||
map.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isProtected() {
|
||||
return !this.isOwned(this.beaconPair);
|
||||
}
|
||||
|
||||
onActivate(ev) {
|
||||
this.handleEventMapEntries(ev, this.onActivateMap);
|
||||
}
|
||||
|
||||
onFocus(ev) {
|
||||
this.handleEventMapEntries(ev, this.onFocusMap);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 { user } from "@web/core/user";
|
||||
import { Component, useEffect, onWillStart, useRef, useState } from '@odoo/owl';
|
||||
|
||||
export class ArticleSelectionDialog extends Component {
|
||||
|
||||
static template = 'knowledge.ArticleSelectionDialog';
|
||||
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.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 user.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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<t t-name="knowledge.ArticleSelectionDialog">
|
||||
<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>
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import { Component, onWillStart, useEffect, useRef, useState } from "@odoo/owl";
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
import { groupBy } from "@web/core/utils/arrays";
|
||||
import { Record } from "@web/model/record";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { KnowledgeHtmlViewer } from "@knowledge/components/knowledge_html_viewer/knowledge_html_viewer";
|
||||
import { WithSubEnv } from "@knowledge/components/with_sub_env/with_sub_env";
|
||||
import { READONLY_MAIN_EMBEDDINGS } from "@html_editor/others/embedded_components/embedding_sets";
|
||||
import { KNOWLEDGE_READONLY_EMBEDDINGS } from "@knowledge/editor/embedded_components/embedding_sets";
|
||||
|
||||
/**
|
||||
* 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,
|
||||
KnowledgeHtmlViewer,
|
||||
WithSubEnv
|
||||
};
|
||||
static props = {
|
||||
onLoadTemplate: { type: Function },
|
||||
close: { type: Function },
|
||||
};
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
setup() {
|
||||
super.setup();
|
||||
this.size = "fs";
|
||||
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}
|
||||
*/
|
||||
getHtmlViewerConfig(record) {
|
||||
return {
|
||||
config: {
|
||||
value: record.data.template_preview,
|
||||
embeddedComponents: [...READONLY_MAIN_EMBEDDINGS, ...KNOWLEDGE_READONLY_EMBEDDINGS],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Array[String]}
|
||||
*/
|
||||
get articleTemplateFieldNames() {
|
||||
return [
|
||||
"cover_image_url",
|
||||
"icon",
|
||||
"id",
|
||||
"parent_id",
|
||||
"template_name",
|
||||
"template_preview",
|
||||
"template_description",
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="knowledge.ArticleTemplatePickerDialog">
|
||||
<Dialog size="size" title.translate="Select a Template" 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">
|
||||
<WithSubEnv model="slot.record.model">
|
||||
<KnowledgeHtmlViewer t-props="this.getHtmlViewerConfig(slot.record)"/>
|
||||
</WithSubEnv>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border 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" data-hotkey="x">Discard</button>
|
||||
</t>
|
||||
</Dialog>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import { useService } from "@web/core/utils/hooks";
|
||||
import { useRecordObserver } from "@web/model/relational_model/utils";
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export default class KnowledgeBreadcrumbs extends Component {
|
||||
static template = "knowledge.KnowledgeBreadcrumbs";
|
||||
static props = {
|
||||
record: Object,
|
||||
};
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.actionService = useService("action");
|
||||
this.articlesIndexes = [this.props.record.resId];
|
||||
this.articleIndex = 0;
|
||||
this.canRestorePreviousAction = this.env.config.breadcrumbs?.length > 1;
|
||||
useRecordObserver((record) => {
|
||||
// When an article is opened, update the array of ids if it was not opened using the
|
||||
// breadcrumbs. For example, if the array of ids is [1,2,3,4] and we are currently on
|
||||
// the article 2 after having clicked twice on the back button, opening article 5
|
||||
// discards the ids after 2 and appends id 5 to the array ([1,2,5])
|
||||
if (record.resId !== this.articlesIndexes[this.articleIndex]) {
|
||||
this.articlesIndexes.splice(
|
||||
++this.articleIndex,
|
||||
this.articlesIndexes.length - this.articleIndex,
|
||||
record.resId,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get isGoBackEnabled() {
|
||||
return this.articleIndex > 0 || this.canRestorePreviousAction;
|
||||
}
|
||||
|
||||
get isGoNextEnabled() {
|
||||
return this.articleIndex < this.articlesIndexes.length - 1;
|
||||
}
|
||||
|
||||
onClickBack() {
|
||||
if (this.isGoBackEnabled) {
|
||||
if (this.articleIndex === 0) {
|
||||
this.actionService.restore();
|
||||
} else {
|
||||
this.env.openArticle(this.articlesIndexes[--this.articleIndex]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onClickNext() {
|
||||
if (this.isGoNextEnabled) {
|
||||
this.env.openArticle(this.articlesIndexes[++this.articleIndex]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
<t t-name="knowledge.KnowledgeBreadcrumbs">
|
||||
<button class="btn btn-light" t-att-class="{'disabled': !isGoBackEnabled}" t-on-click="onClickBack">
|
||||
<i class="oi oi-chevron-left"/>
|
||||
</button>
|
||||
<button class="btn btn-light" t-att-class="{'disabled': !isGoNextEnabled}" t-on-click="onClickNext">
|
||||
<i class="oi oi-chevron-right"/>
|
||||
</button>
|
||||
</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/chatter/web_portal/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">
|
||||
<chatter t-if="this.state.displayChatter" class="o_FormRenderer_chatterContainer 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 })"
|
||||
/>
|
||||
</chatter>
|
||||
</div>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { CustomFavoriteItem } from "@web/search/custom_favorite_item/custom_favorite_item";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
patch(CustomFavoriteItem.prototype, {
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
|
||||
if (this.isKnowledgeEmbeddedView()) {
|
||||
// knowledge favorites are shared for all users
|
||||
this.state.isShared = true;
|
||||
}
|
||||
},
|
||||
|
||||
isKnowledgeEmbeddedView() {
|
||||
return (
|
||||
this.env.searchModel &&
|
||||
this.env.searchModel._context &&
|
||||
this.env.searchModel._context.knowledgeEmbeddedViewId
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-inherit="web.CustomFavoriteItem" t-inherit-mode="extension">
|
||||
<xpath expr="//CheckBox[@value='state.isShared']" position="attributes">
|
||||
<attribute name="t-if">!this.isKnowledgeEmbeddedView()</attribute>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
const cogMenuRegistry = registry.category("cogMenu");
|
||||
|
||||
export class EmbeddedViewActionsMenu extends Component {
|
||||
static props = {};
|
||||
static template = "knowledge.EmbeddedViewActionsMenu";
|
||||
static components = { Dropdown, DropdownItem };
|
||||
|
||||
_onOpenEmbeddedView () {
|
||||
this.env.bus.trigger(`KNOWLEDGE_EMBEDDED_${this.env.searchModel.context.knowledgeEmbeddedViewId}:OPEN`);
|
||||
}
|
||||
_onEditEmbeddedView () {
|
||||
this.env.bus.trigger(`KNOWLEDGE_EMBEDDED_${this.env.searchModel.context.knowledgeEmbeddedViewId}:EDIT`);
|
||||
}
|
||||
}
|
||||
|
||||
cogMenuRegistry.add(
|
||||
'embedded-view-actions-menu',
|
||||
{
|
||||
Component: EmbeddedViewActionsMenu,
|
||||
groupNumber: 10,
|
||||
isDisplayed: (env) => {
|
||||
/**
|
||||
* Those buttons should only be displayed when inside the main Knowledge view.
|
||||
* This means that the context should contain an embedded ID and a context key called
|
||||
* `isOpenedEmbeddedView`. (Which is added when clicking on the open button)
|
||||
*/
|
||||
return env.searchModel.context.knowledgeEmbeddedViewId &&
|
||||
!env.searchModel.context.isOpenedEmbeddedView;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates>
|
||||
<t t-name="knowledge.EmbeddedViewActionsMenu">
|
||||
<DropdownItem onSelected.bind="_onOpenEmbeddedView">
|
||||
<i class="fa fa-fw fa-external-link me-1"/>Open
|
||||
</DropdownItem>
|
||||
<DropdownItem t-if="!env.isEmbeddedReadonly" onSelected.bind="_onEditEmbeddedView">
|
||||
<i class="fa fa-fw fa-pencil me-1"/>Edit
|
||||
</DropdownItem>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
import { Component } from "@odoo/owl";
|
||||
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { user } from "@web/core/user";
|
||||
import { useOwnedDialogs, useService } from "@web/core/utils/hooks";
|
||||
import { omit } from "@web/core/utils/objects";
|
||||
import { renderToElement } from "@web/core/utils/render";
|
||||
import { SelectCreateDialog } from "@web/views/view_dialogs/select_create_dialog";
|
||||
|
||||
const cogMenuRegistry = registry.category("cogMenu");
|
||||
|
||||
const supportedEmbeddedViews = new Set([
|
||||
"calendar",
|
||||
"graph",
|
||||
"hierarchy",
|
||||
"kanban",
|
||||
"list",
|
||||
"pivot",
|
||||
"cohort",
|
||||
"gantt",
|
||||
"map",
|
||||
]);
|
||||
|
||||
class InsertEmbeddedViewMenu extends Component {
|
||||
static props = {};
|
||||
static template = "knowledge.InsertEmbeddedViewMenu";
|
||||
static components = { Dropdown, DropdownItem };
|
||||
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
this.actionService = useService("action");
|
||||
this.addDialog = useOwnedDialogs();
|
||||
this.knowledgeCommandsService = useService("knowledgeCommandsService");
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Object|null} Template props necessary to render an embedded
|
||||
* view in Knowledge, or null if it is not possible
|
||||
* to store this view as an embedded view.
|
||||
*/
|
||||
extractCurrentViewEmbedTemplateProps() {
|
||||
const viewProps = {
|
||||
context: this.getViewContext(),
|
||||
displayName: this.env.config.getDisplayName(),
|
||||
viewType: this.env.config.viewType,
|
||||
};
|
||||
const xmlId = this.actionService.currentController?.action?.xml_id;
|
||||
if (xmlId) {
|
||||
viewProps.actionXmlId = xmlId;
|
||||
return { embeddedProps: { viewProps } };
|
||||
}
|
||||
/**
|
||||
* Recover the original action (before the service pre-processing). The
|
||||
* raw action is needed because it will be pre-processed again as a
|
||||
* "different" action, after being stripped of its id, in Knowledge.
|
||||
* If there is no original action, it means that the action is not
|
||||
* serializable, therefore it cannot be stored in the body of an
|
||||
* article.
|
||||
*/
|
||||
const originalAction = this.actionService.currentController?.action?._originalAction;
|
||||
if (originalAction) {
|
||||
const action = JSON.parse(originalAction);
|
||||
// remove action help as it won't be used
|
||||
delete action.help;
|
||||
return { embeddedProps: { viewProps } };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full context that will be passed to the embedded view.
|
||||
* @returns {Object}
|
||||
*/
|
||||
getViewContext() {
|
||||
const context = {};
|
||||
if (this.env.searchModel) {
|
||||
// Store the context of the search model:
|
||||
Object.assign(
|
||||
context,
|
||||
omit(this.env.searchModel.context, ...Object.keys(user.context))
|
||||
);
|
||||
// Store the state of the search model:
|
||||
Object.assign(context, {
|
||||
knowledge_search_model_state: JSON.stringify(this.env.searchModel.exportState()),
|
||||
});
|
||||
}
|
||||
// Store the "local context" of the view:
|
||||
const fns = this.env.__getContext__.callbacks;
|
||||
const localContext = Object.assign({}, ...fns.map((fn) => fn()));
|
||||
const extraContext = {};
|
||||
this.env.searchModel.trigger("insert-embedded-view", extraContext);
|
||||
Object.assign(context, localContext, extraContext);
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare a Embedded Component rendered in backend to be inserted in an
|
||||
* article by the KnowledgeCommandsService.
|
||||
* Allow to choose an article in a modal, redirect to that article and
|
||||
* append the rendered template "blueprint" needed for the desired Embedded
|
||||
* Component
|
||||
* @param {string} template template name of the embedded blueprint to
|
||||
* render.
|
||||
*/
|
||||
insertCurrentViewInKnowledge(template) {
|
||||
const config = this.env.config;
|
||||
const templateProps = this.extractCurrentViewEmbedTemplateProps();
|
||||
if (config.actionType !== "ir.actions.act_window" || !templateProps) {
|
||||
throw new Error(
|
||||
'This view can not be embedded in an article: the action is not an "ir.actions.act_window" or is not serializable.'
|
||||
);
|
||||
}
|
||||
this.openArticleSelector(async (id) => {
|
||||
this.knowledgeCommandsService.setPendingEmbeddedBlueprint({
|
||||
embeddedBlueprint: renderToElement(template, {
|
||||
embeddedProps: JSON.stringify(templateProps.embeddedProps),
|
||||
}),
|
||||
model: "knowledge.article",
|
||||
field: "body",
|
||||
resId: id,
|
||||
});
|
||||
this.actionService.doAction("knowledge.ir_actions_server_knowledge_home_page", {
|
||||
additionalContext: {
|
||||
res_id: id,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onInsertEmbeddedViewInArticle() {
|
||||
this.insertCurrentViewInKnowledge("knowledge.EmbeddedViewBlueprint");
|
||||
}
|
||||
|
||||
onInsertViewLinkInArticle() {
|
||||
this.insertCurrentViewInKnowledge("knowledge.EmbeddedViewLinkBlueprint");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Function} onSelectCallback
|
||||
*/
|
||||
openArticleSelector(onSelectCallback) {
|
||||
this.addDialog(SelectCreateDialog, {
|
||||
title: _t("Select an article"),
|
||||
noCreate: false,
|
||||
multiSelect: false,
|
||||
resModel: "knowledge.article",
|
||||
context: {},
|
||||
domain: [
|
||||
["user_has_write_access", "=", true],
|
||||
["is_template", "=", false],
|
||||
],
|
||||
onSelected: (resIds) => {
|
||||
onSelectCallback(resIds[0]);
|
||||
},
|
||||
onCreateEdit: async () => {
|
||||
const articleId = await this.orm.call("knowledge.article", "article_create", [], {
|
||||
is_private: true,
|
||||
});
|
||||
onSelectCallback(articleId);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
cogMenuRegistry.add("insert-embedded-view-menu", {
|
||||
Component: InsertEmbeddedViewMenu,
|
||||
groupNumber: 10,
|
||||
isDisplayed: (env) => {
|
||||
// only support act_window with an id for now, but act_window
|
||||
// object could potentially be used too (rework backend API to insert
|
||||
// views in articles)
|
||||
return (
|
||||
env.config.actionId &&
|
||||
!env.searchModel.context.knowledgeEmbeddedViewId &&
|
||||
supportedEmbeddedViews.has(env.config.viewType)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates>
|
||||
<t t-name="knowledge.InsertEmbeddedViewMenu">
|
||||
<Dropdown>
|
||||
<button class="o_knowledge_icon_search opacity-trigger-hover">
|
||||
<t t-call="Knowledge.KnowledgeIcon"/>
|
||||
Knowledge
|
||||
</button>
|
||||
<t t-set-slot="content">
|
||||
<DropdownItem onSelected.bind="onInsertEmbeddedViewInArticle" closingMode="'none'">
|
||||
<i class="oi oi-fw oi-view me-1"/>Insert view in article
|
||||
</DropdownItem>
|
||||
<DropdownItem onSelected.bind="onInsertViewLinkInArticle" closingMode="'none'" >
|
||||
<i class="fa fa-fw fa-link me-1"/>Insert link in article
|
||||
</DropdownItem>
|
||||
</t>
|
||||
</Dropdown>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { useBus } from "@web/core/utils/hooks";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { ListRenderer } from "@web/views/list/list_renderer";
|
||||
|
||||
patch(ListRenderer.prototype, {
|
||||
setup() {
|
||||
super.setup();
|
||||
if (this.env.searchModel) {
|
||||
useBus(this.env.searchModel, "insert-embedded-view", (ev) => {
|
||||
Object.assign(ev.detail, {
|
||||
orderBy: JSON.stringify(this.props.list.orderBy),
|
||||
keyOptionalFields: this.keyOptionalFields,
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* When the user hides/shows some columns from the list view, the system will
|
||||
* add a new cache entry in the local storage of the user and will list all
|
||||
* visible columns for the current view. To make the configuration specific to
|
||||
* a view, the system generates a unique key for the cache entry by using all
|
||||
* available information about the view.
|
||||
*
|
||||
* When loading the view, the system regenerates a key from the current view
|
||||
* and check if there is any entry in the cache for that key. If there is a
|
||||
* match, the system will load the configuration specified in the cache entry.
|
||||
*
|
||||
* For the embedded views of Knowledge, we want the configuration of the view
|
||||
* to be unique for each embedded view. To achieve that, we will overwrite the
|
||||
* function generating the key for the cache entry and include the unique id
|
||||
* of the embedded view.
|
||||
*
|
||||
* @override
|
||||
* @returns {string}
|
||||
*/
|
||||
createViewKey() {
|
||||
if (this.env.searchModel?.context.knowledgeEmbeddedViewId) {
|
||||
return `${super.createViewKey()},${this.env.searchModel.context.knowledgeEmbeddedViewId}`;
|
||||
}
|
||||
return super.createViewKey();
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { FormStatusIndicator } from "@web/views/form/form_status_indicator/form_status_indicator";
|
||||
|
||||
/**
|
||||
* This extension of the FormStatusIndicator is used to add a new indicator to the ones that already
|
||||
* exists. This new icon is used in the same way as the icon in Google Docs => indicate that all changes
|
||||
* have been committed to the DB.
|
||||
*/
|
||||
export class KnowledgeFormStatusIndicator extends FormStatusIndicator {
|
||||
static template = 'knowledge.FormStatusIndicator';
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="knowledge.FormStatusIndicator" t-inherit="web.FormStatusIndicator">
|
||||
<xpath expr="//div[hasclass('o_form_status_indicator_buttons')]" position="before">
|
||||
<img src="/knowledge/static/src/img/form_status_indicator_saved.svg"
|
||||
class="align-items-center d-flex px-1 py-0 border border-transparent user-select-none"
|
||||
t-attf-class="{{ displayButtons ? 'd-none' : ''}}"
|
||||
data-tooltip="All changes saved"/>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { useRecordObserver } from "@web/model/relational_model/utils";
|
||||
|
||||
import KnowledgeBreadcrumbs from "@knowledge/components/breadcrumbs/breadcrumbs";
|
||||
import KnowledgeIcon from "@knowledge/components/knowledge_icon/knowledge_icon";
|
||||
|
||||
import { Component, useState } from "@odoo/owl";
|
||||
|
||||
export default class KnowledgeHierarchy extends Component {
|
||||
static components = {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
KnowledgeBreadcrumbs,
|
||||
KnowledgeIcon,
|
||||
};
|
||||
static props = { record: Object };
|
||||
static template = "knowledge.KnowledgeHierarchy";
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.orm = useService("orm");
|
||||
this.state = useState({
|
||||
articleName: this.props.record.data.name,
|
||||
isLoadingArticleHierarchy: false,
|
||||
});
|
||||
useRecordObserver((record) => {
|
||||
if (this.state.articleName !== record.data.name) {
|
||||
this.state.articleName = record.data.name;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to display the dropdown toggle used to get the articles that are between the root
|
||||
* and the parent article. It is only shown if there are any articles to show (parent_path is
|
||||
* of the form "1/2/3/4/", hence length > 4 as condition)
|
||||
*/
|
||||
get displayDropdownToggle() {
|
||||
return this.props.record.data.parent_path.split("/").length > 4;
|
||||
}
|
||||
|
||||
get isReadonly() {
|
||||
return this.props.record.data.is_locked || !this.props.record.data.user_can_write;
|
||||
}
|
||||
|
||||
get parentId() {
|
||||
return this.props.record.data.parent_id?.[0];
|
||||
}
|
||||
|
||||
get parentName() {
|
||||
return this.props.record.data.parent_id?.[1];
|
||||
}
|
||||
|
||||
get rootId() {
|
||||
return this.props.record.data.root_article_id[0];
|
||||
}
|
||||
|
||||
get rootName() {
|
||||
return this.props.record.data.root_article_id[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the articles that should be shown in the dropdown
|
||||
*/
|
||||
async loadHierarchy() {
|
||||
this.articleHierarchy = await this.orm.call(
|
||||
"knowledge.article",
|
||||
"get_article_hierarchy",
|
||||
[this.props.record.resId],
|
||||
{ exclude_article_ids: [this.rootId, this.parentId, this.props.record.resId] },
|
||||
);
|
||||
this.state.isLoadingArticleHierarchy = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* If needed, will show the loading indicator in the dropdown and start the loading
|
||||
* of the articles to show in it
|
||||
*/
|
||||
async onBeforeOpen() {
|
||||
this.state.isLoadingArticleHierarchy = true;
|
||||
this.loadHierarchy();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates>
|
||||
<t t-name="knowledge.KnowledgeHierarchy">
|
||||
<KnowledgeBreadcrumbs record="props.record"/>
|
||||
<!-- Root Article -->
|
||||
<div t-if="rootId !== props.record.resId" class="o_hierarchy_item d-flex min-w-0 align-items-center">
|
||||
<a role="button" class="btn btn-light border-0 px-2 text-truncate me-1"
|
||||
t-on-click="() => this.env.openArticle(this.rootId)"
|
||||
t-out="rootName"
|
||||
t-att-title="rootName"/>
|
||||
<i class="fa fa-caret-right"/>
|
||||
</div>
|
||||
<!-- Dropdown with all articles between the root and the direct parent -->
|
||||
<div t-if="displayDropdownToggle" class="o_hierarchy_item d-flex w-auto align-items-center">
|
||||
<Dropdown beforeOpen.bind="onBeforeOpen">
|
||||
<a type="button" class="btn btn-light border-0 px-2 me-1">
|
||||
...
|
||||
</a>
|
||||
<t t-set-slot="content">
|
||||
<i t-if="state.isLoadingArticleHierarchy" class="fa fa-spin fa-circle-o-notch mx-auto"/>
|
||||
<t t-else="">
|
||||
<t t-set="notAccessibleTitle">You do not have access to this article</t>
|
||||
<t t-foreach="articleHierarchy" t-as="article" t-key="article.id">
|
||||
<DropdownItem onSelected="() => this.env.openArticle(article.id)"
|
||||
class="article.user_has_access ? '' : 'disabled pe-auto'">
|
||||
<span t-out="article.display_name"
|
||||
t-att-title="article.user_has_access ? article.display_name : notAccessibleTitle"/>
|
||||
</DropdownItem>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</Dropdown>
|
||||
<i class="fa fa-caret-right"/>
|
||||
</div>
|
||||
<!-- Parent Article -->
|
||||
<div t-if="parentId and parentId !== rootId" class="o_hierarchy_item d-flex min-w-0 align-items-center">
|
||||
<a role="button" class="btn btn-light border-0 px-2 text-truncate me-1"
|
||||
t-on-click="() => this.env.openArticle(this.parentId)"
|
||||
t-out="parentName"
|
||||
t-att-title="parentName"/>
|
||||
<i class="fa fa-caret-right"/>
|
||||
</div>
|
||||
<!-- Current Article -->
|
||||
<div class="o_hierarchy_item d-flex min-w-0 align-items-center ms-2">
|
||||
<KnowledgeIcon record="props.record" fallbackDefaultIcon="true" iconClasses="'me-1'" readonly="isReadonly"/>
|
||||
<div t-if="isReadonly" class="text-truncate me-1 fw-bold">
|
||||
<t t-if="this.props.record.data.name" t-out="this.props.record.data.name" />
|
||||
<t t-else="">Untitled</t>
|
||||
</div>
|
||||
<div t-else="" class="o_hierarchy_article_name position-relative text-truncate" t-on-click="env.ensureArticleName">
|
||||
<input class="o_input position-absolute top-50 start-50 translate-middle fw-bold text-truncate text-700 border-0"
|
||||
t-model="state.articleName"
|
||||
t-on-change="ev => this.env.renameArticle(ev.target.value)"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Untitled"/>
|
||||
<!-- span forces the input to match the length of its value -->
|
||||
<span class="d-inline-block pe-1 fw-bold text-truncate invisible">
|
||||
<t t-if="state.articleName" t-out="state.articleName"/>
|
||||
<t t-else="">Untitled</t>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,293 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
import { SCALE_LABELS } from "@web/views/calendar/calendar_controller";
|
||||
import { SelectMenu } from "@web/core/select_menu/select_menu";
|
||||
import { useAutofocus, useService } from "@web/core/utils/hooks";
|
||||
import { uuid } from "@web/views/utils";
|
||||
|
||||
import { Component, onWillStart, useExternalListener, useState } from "@odoo/owl";
|
||||
|
||||
/**
|
||||
* Dialog allowing to dynamically edit the item calendar view configuration
|
||||
* (the "itemCalendarProps"). Used when clicking on the "edit" button of the
|
||||
* embedded view manager.
|
||||
*/
|
||||
export class ItemCalendarPropsDialog extends Component {
|
||||
static template = "knowledge.ItemCalendarPropsDialog";
|
||||
static components = {
|
||||
DropdownItem,
|
||||
Dialog,
|
||||
SelectMenu,
|
||||
};
|
||||
static props = {
|
||||
knowledgeArticleId: { type: Number, optional: true },
|
||||
close: { type: Function, optional: true },
|
||||
colorPropertyId: { type: String, optional: true },
|
||||
dateStartPropertyId: { type: String, optional: true },
|
||||
dateStopPropertyId: { type: String, optional: true },
|
||||
dateType: { type: String, optional: true },
|
||||
isNew: { type: Boolean, optional: true },
|
||||
name: { type: String, optional: true },
|
||||
saveItemCalendarProps: { type: Function },
|
||||
scale: { type: String, optional: true },
|
||||
showWeekEnds: { type: Boolean, optional: true },
|
||||
slotMaxTime: { type: String, optional: true },
|
||||
slotMinTime: { type: String, optional: true },
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
showWeekEnds: true,
|
||||
slotMinTime: "00:00",
|
||||
slotMaxTime: "24:00",
|
||||
};
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
useAutofocus();
|
||||
this.orm = useService("orm");
|
||||
this.notification = useService("notification");
|
||||
this.state = useState({
|
||||
name: this.props.name,
|
||||
scale: this.props.scale || "week",
|
||||
showWeekEnds: this.props.showWeekEnds,
|
||||
slotMaxTime: this.props.slotMaxTime,
|
||||
slotMinTime: this.props.slotMinTime,
|
||||
});
|
||||
this.colorChoices = [];
|
||||
this.dateChoices = [];
|
||||
this.dateTimeChoices = [];
|
||||
this.propertyFieldEntries = {};
|
||||
this.scalesChoices = Object.entries(SCALE_LABELS).map(([value, label]) => ({ value, label }));
|
||||
|
||||
onWillStart(async () => {
|
||||
// Fetch the properties definitions and create choices to use in
|
||||
// the SelectMenu components
|
||||
const propertiesDefinitions = await this.orm.read(
|
||||
"knowledge.article",
|
||||
[this.props.knowledgeArticleId],
|
||||
["article_properties_definition"]
|
||||
);
|
||||
this.propertiesDefinitions = propertiesDefinitions[0].article_properties_definition;
|
||||
for (const definition of this.propertiesDefinitions) {
|
||||
this.propertyFieldEntries[definition.name] = {
|
||||
label: definition.string,
|
||||
type: definition.type,
|
||||
};
|
||||
// Add choice in corresponding dropdown
|
||||
const choice = {
|
||||
value: definition.name,
|
||||
label: definition.string,
|
||||
};
|
||||
if (definition.type === "datetime") {
|
||||
this.dateTimeChoices.push(choice);
|
||||
} else if (definition.type === "date") {
|
||||
this.dateChoices.push(choice);
|
||||
} else if (["boolean", "many2one", "selection"].includes(definition.type)) {
|
||||
this.colorChoices.push(choice);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.props.isNew) {
|
||||
// If no date(time) properties exists, create default ones
|
||||
if (!this.dateTimeChoices.length && !this.dateChoices.length) {
|
||||
this.autoCreateDateProperties = true;
|
||||
this.createProperty(_t("Start Date Time"), "datetime", "dateStart");
|
||||
this.createProperty(_t("End Date Time"), "datetime", "dateStop");
|
||||
} else {
|
||||
// If some exist, select them by default (prefer to use 2
|
||||
// of the same type if possible, and prefer datetimes over
|
||||
// dates)
|
||||
if (this.dateTimeChoices.length > 1) {
|
||||
this.selectDateStart(this.dateTimeChoices[0].value);
|
||||
this.selectDateStop(this.dateTimeChoices[1].value);
|
||||
} else if (this.dateChoices.length > 1) {
|
||||
this.selectDateStart(this.dateChoices[0].value);
|
||||
this.selectDateStop(this.dateChoices[1].value);
|
||||
} else if (this.dateTimeChoices.length !== 0) {
|
||||
this.selectDateStart(this.dateTimeChoices[0].value);
|
||||
} else {
|
||||
this.selectDateStart(this.dateChoices[0].value);
|
||||
}
|
||||
|
||||
}
|
||||
} else {
|
||||
// Use props only if the related property still exists
|
||||
for (const propName of ["dateStartPropertyId", "dateStopPropertyId", "colorPropertyId"]) {
|
||||
if (this.propertyFieldEntries[this.props[propName]]) {
|
||||
this.state[propName] = this.props[propName];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Save when pressing on enter
|
||||
useExternalListener(window, "keydown", (event) => {
|
||||
if (event.key === "Enter") {
|
||||
this.save();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the available start properties formatted as groups (date and
|
||||
* datetime) for the SelectMenu component
|
||||
*/
|
||||
get availableDateStartProperties() {
|
||||
return [{
|
||||
label: _t("Date and Time Properties"),
|
||||
choices: this.dateTimeChoices
|
||||
}, {
|
||||
label: _t("Date Properties"),
|
||||
choices: this.dateChoices,
|
||||
}];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the available stop properties (that are of the same type as the
|
||||
* current start date) formatted as a group for the SelectMenu component
|
||||
*/
|
||||
get availableDateStopProperties() {
|
||||
// Don't show current start date nor dates with other type
|
||||
if (this.dateStartProperty?.type === "datetime") {
|
||||
return [{
|
||||
label: _t("Date and Time Properties"),
|
||||
choices: this.dateTimeChoices.filter(choice => choice.value !== this.state.dateStartPropertyId),
|
||||
}];
|
||||
}
|
||||
return [{
|
||||
label: _t("Date Properties"),
|
||||
choices: this.dateChoices.filter(choice => choice.value !== this.state.dateStartPropertyId),
|
||||
}];
|
||||
}
|
||||
|
||||
get colorProperty() {
|
||||
return this.propertyFieldEntries[this.state.colorPropertyId];
|
||||
}
|
||||
|
||||
get dateStartProperty() {
|
||||
return this.propertyFieldEntries[this.state.dateStartPropertyId];
|
||||
}
|
||||
|
||||
get dateStopProperty() {
|
||||
return this.propertyFieldEntries[this.state.dateStopPropertyId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new date or datetime property and use it as start or stop
|
||||
* choice.
|
||||
* Note: only the new properties that are selected when saving will be
|
||||
* stored.
|
||||
* @param {string} label: label of the property
|
||||
* @param {string} type: type of the property (date or datetime)
|
||||
* @param {string} calendarProp: for which calendar prop the property has
|
||||
* been created
|
||||
*/
|
||||
createProperty(label, type, calendarProp) {
|
||||
const newPropertyId = uuid();
|
||||
// Add to list of properties
|
||||
this.propertyFieldEntries[newPropertyId] = {
|
||||
isNew: true,
|
||||
label: label,
|
||||
type: type,
|
||||
};
|
||||
// Add new choice in dropdowns
|
||||
const newChoice = {
|
||||
value: newPropertyId,
|
||||
label,
|
||||
};
|
||||
if (type === "date") {
|
||||
this.dateChoices.push(newChoice);
|
||||
} else if (type === "datetime") {
|
||||
this.dateTimeChoices.push(newChoice);
|
||||
} else {
|
||||
this.colorChoices.push(newChoice);
|
||||
}
|
||||
// Select the new choice
|
||||
if (calendarProp === "dateStart") {
|
||||
this.selectDateStart(newPropertyId);
|
||||
} else if (calendarProp === "dateStop") {
|
||||
this.selectDateStop(newPropertyId);
|
||||
} else if (calendarProp === "color") {
|
||||
this.selectColor(newPropertyId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the item calendar props and close the dialog. If there is a new
|
||||
* choice selected as start and/or stop date, create the associated
|
||||
* property(ies) first.
|
||||
*/
|
||||
async save() {
|
||||
if (!this.state.dateStartPropertyId) {
|
||||
this.notification.add(_t("The start date property is required."), {type: "danger"});
|
||||
return;
|
||||
}
|
||||
// Create new property if needed
|
||||
if (this.dateStartProperty.isNew || this.dateStopProperty?.isNew || this.colorProperty?.isNew) {
|
||||
// Keep existing properties to not lose them.
|
||||
const propertiesDefinitions = [...this.propertiesDefinitions];
|
||||
if (this.dateStartProperty.isNew) {
|
||||
propertiesDefinitions.push({
|
||||
name: this.state.dateStartPropertyId,
|
||||
string: this.dateStartProperty.label,
|
||||
type: this.dateStartProperty.type,
|
||||
});
|
||||
}
|
||||
if (this.dateStopProperty?.isNew) {
|
||||
propertiesDefinitions.push({
|
||||
name: this.state.dateStopPropertyId,
|
||||
string: this.dateStopProperty.label,
|
||||
type: this.dateStopProperty.type,
|
||||
});
|
||||
}
|
||||
if (this.colorProperty?.isNew) {
|
||||
propertiesDefinitions.push({
|
||||
name: this.state.colorPropertyId,
|
||||
string: this.colorProperty.label,
|
||||
type: this.colorProperty.type,
|
||||
});
|
||||
}
|
||||
try {
|
||||
await this.orm.write("knowledge.article", [this.props.knowledgeArticleId], {article_properties_definition: propertiesDefinitions});
|
||||
} catch (e) {
|
||||
this.notification.add(_t("New property could not be created."), {type: "danger"});
|
||||
console.error(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.props.saveItemCalendarProps(this.state.name, {
|
||||
dateStartPropertyId: this.state.dateStartPropertyId,
|
||||
dateStopPropertyId: this.state.dateStopPropertyId || undefined,
|
||||
colorPropertyId: this.state.colorPropertyId || undefined,
|
||||
scale: this.state.scale,
|
||||
dateType: this.dateStartProperty.type,
|
||||
showWeekEnds: this.state.showWeekEnds,
|
||||
slotMinTime: this.state.slotMinTime || "00:00",
|
||||
slotMaxTime: this.state.slotMaxTime || "24:00",
|
||||
});
|
||||
this.props.close();
|
||||
}
|
||||
|
||||
selectColor(value) {
|
||||
this.state.colorPropertyId = value;
|
||||
}
|
||||
|
||||
selectDateStop(value) {
|
||||
this.state.dateStopPropertyId = value;
|
||||
}
|
||||
|
||||
selectScale(value) {
|
||||
this.state.scale = value;
|
||||
}
|
||||
|
||||
selectDateStart(value) {
|
||||
this.state.dateStartPropertyId = value;
|
||||
// DateStop must be of the same type as start, but must not be the same property
|
||||
if (this.state.dateStopPropertyId && (this.dateStartProperty.type !== this.dateStopProperty.type || this.state.dateStopPropertyId === this.state.dateStartPropertyId)) {
|
||||
this.state.dateStopPropertyId = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
.o_knowledge_item_calendar_props_dialog {
|
||||
.o_select_menu input.dropdown-item {
|
||||
padding-block: 0.5rem !important;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue