575 lines
23 KiB
JavaScript
575 lines
23 KiB
JavaScript
/** @odoo-module */
|
|
import { useBus, useService } from '@web/core/utils/hooks';
|
|
import { usePopover } from '@web/core/popover/popover_hook';
|
|
import { Composer } from '@mail/core/common/composer';
|
|
import { Thread } from '@mail/core/common/thread';
|
|
import { KnowledgeCommentsPopover } from '../comments_popover/comments_popover';
|
|
import { KnowledgeCommentCreatorComposer } from '../composer/composer';
|
|
import { closestBlock } from '@web_editor/js/editor/odoo-editor/src/utils/utils';
|
|
import { _t } from '@web/core/l10n/translation';
|
|
|
|
import {
|
|
Component,
|
|
onMounted,
|
|
onPatched,
|
|
onWillStart,
|
|
onWillUpdateProps,
|
|
useChildSubEnv,
|
|
useExternalListener,
|
|
useRef,
|
|
useEffect,
|
|
useState
|
|
} from '@odoo/owl';
|
|
|
|
export class KnowledgeCommentsThread extends Component {
|
|
|
|
static components = { Composer, Thread, KnowledgeCommentCreatorComposer };
|
|
static props = {
|
|
articleId: {type: Number, optional: true},
|
|
anchors: {type: Array, optional: true},
|
|
changeCommentResolvedState: Function,
|
|
destroyComment: Function,
|
|
forceFullSize: {type: Boolean, optional: true},
|
|
insertNewThread: {type: Function, optional: true},
|
|
isCreationMode: {type: Boolean, optional: true},
|
|
isResolved: Boolean,
|
|
knowledgeThreadId: {type: Number, optional: true},
|
|
/**@see \@web/model/relational_model/record */
|
|
record: Object,
|
|
/**@see \@mail/core/common/thread_model*/
|
|
thread: {type: Object, optional: true},
|
|
top: {type: Number, optional: true},
|
|
writeDate: {optional: true}
|
|
};
|
|
static template = 'knowledge.KnowledgeCommentsThread';
|
|
|
|
setup() {
|
|
this.targetRef = useRef('targetRef');
|
|
|
|
this.threadService = useService('mail.thread');
|
|
this.messageService = useService('mail.message');
|
|
this.userService = useService('user');
|
|
this.orm = useService('orm');
|
|
this.uiService = useService('ui');
|
|
this.popover = usePopover(KnowledgeCommentsPopover, {
|
|
closeOnClickAway: false,
|
|
position: 'bottom-start',
|
|
popoverClass: 'overflow-auto mh-50 o_knowledge_comments_popover'
|
|
});
|
|
|
|
this.messages = [];
|
|
|
|
this.composerDivRef = useRef('composerDivRef');
|
|
|
|
// Array of all the anchors linked to a single comment
|
|
this.anchors = this.props.anchors;
|
|
// Main anchor used for the positioning
|
|
this.mainAnchor = this.anchors[0];
|
|
|
|
this.state = useState({
|
|
loading: true,
|
|
commenting: false,
|
|
thread: this.props.thread,
|
|
isResolved: this.props.isResolved,
|
|
smallUI: !this.props.forceFullSize &&
|
|
(
|
|
this.props.record.data.full_width ||
|
|
this.isSmallUINeeded()
|
|
),
|
|
// This array contains the text of the anchors selected by the user to create the comment.
|
|
// Each element of this array corresponds to the text of a paragraph.
|
|
quotes: [],
|
|
knowledgeThreadId: this.props.knowledgeThreadId,
|
|
top: this.props.top
|
|
});
|
|
|
|
useChildSubEnv({
|
|
// 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.
|
|
chatter: false,
|
|
closeThread: this.resolveComment.bind(this),
|
|
openThread: this.unresolveComment.bind(this),
|
|
isResolved: this.isResolved
|
|
});
|
|
|
|
this.placeholder = _t('Add a Comment...');
|
|
|
|
useBus(this.uiService.bus, 'resize', () => {
|
|
this.state.smallUI = this.isSmallUINeeded();
|
|
});
|
|
useBus(this.env.bus, `KNOWLEDGE_COMMENT_${this.state.knowledgeThreadId}:HIGHLIGHT`, () => {
|
|
this.state.commenting = true;
|
|
if (!this.state.isResolved && this.state.smallUI) {
|
|
this.openPopover({ target: this.targetRef.el });
|
|
}
|
|
this.highlightComment(true);
|
|
});
|
|
useBus(this.env.bus, `KNOWLEDGE_COMMENT_${this.state.knowledgeThreadId}:COMPUTE_POSITION`, ({detail}) => {
|
|
if (detail) {
|
|
this.anchors = detail.newAnchors;
|
|
this.mainAnchor = this.anchors[0];
|
|
}
|
|
const oldTop = this.top;
|
|
this.setTopPosition();
|
|
// Only update the position if we have a significant change to avoid flickers
|
|
if (Math.abs(oldTop - this.top) > this.mainAnchor.getBoundingClientRect().height) {
|
|
this.state.top = this.top;
|
|
}
|
|
});
|
|
useBus(this.env.bus, 'KNOWLEDGE:CLOSE_COMMENT', () => this.state.commenting = false);
|
|
useBus(this.env.bus, 'mail.thread/onUpdate', ({ detail }) => {
|
|
if (detail.data.isLoaded && detail.thread.model === 'knowledge.article.thread') {
|
|
this.state.lastPartnerId = detail.thread.messages.at(-1)?.author.id || this.userService.partnerId;
|
|
}
|
|
});
|
|
|
|
/**
|
|
* This observer checks for mutation inside the previous sibling of the box and the box itself.
|
|
*/
|
|
this.observer = new MutationObserver(() => {
|
|
if (this.targetRef.el) {
|
|
const oldTop = this.top;
|
|
if (!this.mainAnchor?.parentNode) {
|
|
return;
|
|
}
|
|
|
|
this.setTopPosition();
|
|
// Only update the position if we have a significant change to avoid flickers
|
|
if (Math.abs(oldTop - this.top) > this.mainAnchor?.getBoundingClientRect().height) {
|
|
this.state.top = this.top;
|
|
}
|
|
}
|
|
});
|
|
|
|
useExternalListener(window, 'click', this.endCommenting.bind(this));
|
|
|
|
onWillUpdateProps((nextProps) => {
|
|
this.anchors = nextProps.anchors;
|
|
this.mainAnchor = nextProps.anchors[0];
|
|
});
|
|
onMounted(() => {
|
|
this.state.loading = false;
|
|
this.highlightComment(false);
|
|
// If the ID of the thread is 0 it is considered as a virtual comment, it is in the
|
|
// phase of being created but doesn't exist currently. This way we avoid creating records
|
|
// that will not
|
|
if (this.props.isCreationMode) {
|
|
this.state.commenting = true;
|
|
if (this.state.smallUI) {
|
|
this.openPopover({target: this.targetRef.el});
|
|
} else {
|
|
this.composerDivRef.el.querySelector('textarea')?.focus();
|
|
}
|
|
this.highlightComment(true);
|
|
}
|
|
const paragraphs = new Map();
|
|
for (const anchor of this.anchors) {
|
|
const closestParagraph = closestBlock(anchor);
|
|
if (!paragraphs.has(closestParagraph)) {
|
|
paragraphs.set(closestParagraph, anchor.textContent);
|
|
} else {
|
|
paragraphs.set(closestParagraph, paragraphs.get(closestParagraph) + anchor.textContent);
|
|
}
|
|
}
|
|
this.state.quotes = Array.from(paragraphs.values());
|
|
if (!this.props.forceFullSize) {
|
|
if (this.state.isResolved && this.mainAnchor?.classList.contains('highlighted-comment')) {
|
|
this.resolveComment();
|
|
return;
|
|
}
|
|
this.setTopPosition();
|
|
if (this.targetRef.el) {
|
|
this.state.top = this.top;
|
|
this.targetRef.el.closest('.o_scroll_view_lg').addEventListener('scroll', this.onScroll.bind(this));
|
|
}
|
|
}
|
|
});
|
|
|
|
onPatched(() => {
|
|
if (this.autoFocusTextarea) {
|
|
this.composerDivRef.el?.querySelector('textarea')?.focus();
|
|
this.autoFocusTextarea = false;
|
|
}
|
|
const { messages } = this.threadService.store.get(this.state.thread.localId);
|
|
if (messages.length && messages.every((message) => !message.body)) {
|
|
this.props.destroyComment(this.state.knowledgeThreadId, this.mainAnchor, true);
|
|
return;
|
|
}
|
|
if (!this.props.forceFullSize) {
|
|
this.setTopPosition();
|
|
this.updateHorizontalDimensions();
|
|
if (this.targetRef.el) {
|
|
this.state.top = this.top;
|
|
}
|
|
}
|
|
});
|
|
|
|
onWillStart(async () => {
|
|
this.messages = await this.threadService.fetchMessages(this.state.thread);
|
|
if (this.state.smallUI && this.state.thread) {
|
|
const nonEmptyMessages = this.messages.some((message) => message.body);
|
|
if (this.messages.length && !nonEmptyMessages) {
|
|
this.props.destroyComment(this.state.knowledgeThreadId, this.mainAnchor, true);
|
|
return;
|
|
}
|
|
this.state.lastPartnerId = this.messages.length ? this.messages.at(-1)?.author.id : this.userService.partnerId;
|
|
} else if (!this.state.thread) {
|
|
this.state.lastPartnerId = this.userService.partnerId;
|
|
}
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!this.state.smallUI && this.props.knowledgeThreadId) {
|
|
const observer = new MutationObserver((mutationList) => {
|
|
const mutationsWithEmptyThread = mutationList.some((mutation) => mutation.target.querySelector('.o-mail-Thread-empty'));
|
|
if (mutationsWithEmptyThread) {
|
|
this.props.destroyComment(this.state.knowledgeThreadId, this.mainAnchor, true);
|
|
return;
|
|
}
|
|
});
|
|
observer.observe(this.targetRef.el.querySelector('.o-mail-Thread'), {subtree:true, childList: true});
|
|
return () => observer.disconnect();
|
|
}
|
|
|
|
}, () => []);
|
|
|
|
useEffect((isFullWidth) => {
|
|
this.state.smallUI = isFullWidth;
|
|
this.updateHorizontalDimensions(isFullWidth);
|
|
}, () => [this.props.record.data.full_width]);
|
|
|
|
if (!this.props.forceFullSize) {
|
|
useEffect(() => {
|
|
const observer = new ResizeObserver(() => {
|
|
this.updateHorizontalDimensions();
|
|
});
|
|
observer.observe(this.editable.parentElement);
|
|
return () => observer.disconnect();
|
|
}, () => []);
|
|
useEffect((el, sibling, childNodes) => {
|
|
if (el) {
|
|
this.observer.observe(el, {subtree: true, childList: true});
|
|
this.observer.observe(el.parentElement, {childList: true, subtree: true, attributeFilter: ['style']});
|
|
}
|
|
let removeEventListener = false;
|
|
if (sibling) {
|
|
this.observer.observe(sibling, {subtree: true, attributeFilter: ['style'], attributeOldValue: true});
|
|
if (sibling.querySelector('.o_knowledge_comment_box_thread')) {
|
|
this.observer.observe(sibling.querySelector('.o_knowledge_comment_box_thread'), {childList: true, subtree:true});
|
|
}
|
|
const composerDivContainer = sibling.querySelector('.o_knowledge_comments_buttons');
|
|
if (composerDivContainer) {
|
|
composerDivContainer.addEventListener('input', () => {
|
|
this.setTopPosition();
|
|
if (this.targetRef.el) {
|
|
this.state.top = this.top;
|
|
}
|
|
});
|
|
removeEventListener = () => composerDivContainer.removeEventListener('input', () => {
|
|
this.setTopPosition();
|
|
if (this.targetRef.el) {
|
|
this.state.top = this.top;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
return () => {
|
|
if (removeEventListener) {
|
|
removeEventListener();
|
|
}
|
|
this.observer.disconnect();
|
|
};
|
|
}, () => {
|
|
if (!this.targetRef.el?.previousElementSibling) {
|
|
return [this.targetRef.el];
|
|
}
|
|
return [this.targetRef.el, this.targetRef.el.previousElementSibling];
|
|
});
|
|
}
|
|
}
|
|
|
|
/**======================
|
|
* Getters
|
|
*========================**/
|
|
|
|
get editable() {
|
|
return document.querySelector(
|
|
`.o_field_html .note-editable,
|
|
.o_field_knowledge_article_html_field .note-editable,
|
|
.o_field_knowledge_article_html_field .o_readonly`
|
|
);
|
|
}
|
|
/**
|
|
* Used for the message actions
|
|
*/
|
|
get isResolved() {
|
|
return this.state.isResolved;
|
|
}
|
|
|
|
get textInputContent() {
|
|
return this.props.knowledgeThreadId && this.state.thread.composer.textInputContent;
|
|
}
|
|
|
|
|
|
/**======================
|
|
* Position Computations
|
|
*========================**/
|
|
|
|
isSmallUINeeded() {
|
|
const maxWidth = this.editable.closest('.o_field_knowledge_article_html_field').getBoundingClientRect().right - this.editable.getBoundingClientRect().right;
|
|
return maxWidth <= 300;
|
|
}
|
|
|
|
onScroll() {
|
|
if (this.props.forceFullSize || !this.targetRef.el) {
|
|
return;
|
|
}
|
|
const oldTop = this.top;
|
|
if (!this.mainAnchor?.parentNode) {
|
|
return;
|
|
}
|
|
this.setTopPosition();
|
|
if (this.targetRef.el && Math.abs(oldTop - this.top) > this.mainAnchor?.getBoundingClientRect().height) {
|
|
this.targetRef.el.style.top = `${this.top}px`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* This function computes the vertical position of the comment box and sets the variable this.top
|
|
* to the new top value that the box should be positioned at.
|
|
* To compute the position we take into account the top position of the anchor and we check if
|
|
* at this position we don't intersect with any other box. If that's the case then the new top
|
|
* position is the bottom of the intersected box, this way we do not have any intersections
|
|
* between them.
|
|
*/
|
|
setTopPosition() {
|
|
if (!this.targetRef.el) {
|
|
return;
|
|
}
|
|
const rootTop = document.querySelector('.o_knowledge_body').getBoundingClientRect().top;
|
|
if (!this.mainAnchor?.parentNode) {
|
|
return;
|
|
}
|
|
const targetTop = this.mainAnchor.getBoundingClientRect().top;
|
|
const newTop = Math.abs(rootTop - targetTop);
|
|
const sibling = this.targetRef.el.previousElementSibling;
|
|
if (!sibling) {
|
|
this.top = Math.round(newTop);
|
|
return;
|
|
}
|
|
const siblingRect = sibling.getBoundingClientRect();
|
|
const needsRepositioning = targetTop < siblingRect.bottom;
|
|
// 2 is to add a space between boxes, it is so that they are not directly set one below the other
|
|
const previousBottom = siblingRect.bottom - rootTop + 2;
|
|
|
|
this.top = Math.round(!needsRepositioning ? newTop : previousBottom);
|
|
}
|
|
|
|
/**
|
|
* This functions redimensions horizontally the Component to let it fit in the right column.
|
|
* @param {boolean} forcedSmallUI Do we need to force the small UI to the component
|
|
*/
|
|
updateHorizontalDimensions(forcedSmallUI=false) {
|
|
if (!this.targetRef.el) {
|
|
return;
|
|
}
|
|
// 400px is the maxWidth set in the css file for the class .o_knowledge_comment_box.
|
|
const boxMaxWidth = 400;
|
|
const limitRight = this.editable.parentElement.getBoundingClientRect().right;
|
|
const maxWidth = limitRight - this.editable.getBoundingClientRect().right;
|
|
const commentWidth = this.targetRef.el.getBoundingClientRect().width;
|
|
this.state.smallUI = !this.props.forceFullSize && (forcedSmallUI || this.isSmallUINeeded());
|
|
if (this.state.smallUI) {
|
|
this.state.lastPartnerId = this.messages.at(-1)?.author.id || this.userService.partnerId;
|
|
this.state.width = '';
|
|
// 1cm is the size of the margin that was decided to set when an article is not full size but
|
|
// still triggers the small UI mode. 1rem is the size of the
|
|
this.state.marginRight = this.props.record.data.full_width ? 'margin-right: 1rem' : 'margin-right: 1cm';
|
|
return;
|
|
}
|
|
if (this.popover.isOpen) {
|
|
this.popover.close();
|
|
}
|
|
this.originalWidth = this.originalWidth ? this.originalWidth : boxMaxWidth;
|
|
if (maxWidth > boxMaxWidth) {
|
|
this.state.marginRight = `margin-right: ${ maxWidth/2 - boxMaxWidth/2 }px`;
|
|
this.state.width = `width: ${ boxMaxWidth }px`;
|
|
} else {
|
|
this.state.width = `width: ${ commentWidth - (commentWidth - maxWidth) }px`;
|
|
this.state.marginRight = this.props.record.data.full_width ? '' : 'margin-right: 1cm';
|
|
}
|
|
}
|
|
|
|
/**======================
|
|
* Popover actions
|
|
*========================**/
|
|
|
|
closePopover() {
|
|
this.popover.close();
|
|
this.state.commenting = false;
|
|
this.highlightComment(false);
|
|
this.mainAnchor?.dispatchEvent(new Event('focusout'));
|
|
}
|
|
|
|
|
|
openPopover(ev) {
|
|
if (!this.popover.isOpen) {
|
|
this.state.commentingId = true;
|
|
const popoverProps = {
|
|
knowledgeThreadId: this.state.knowledgeThreadId,
|
|
record: this.props.record,
|
|
thread: this.state.thread,
|
|
closePopover: this.closePopover.bind(this),
|
|
resolveComment: this.resolveComment.bind(this),
|
|
insertNewThread: this.insertNewThread.bind(this)
|
|
};
|
|
this.popover.open(ev.target.closest('.o_knowledge_comment_box'), popoverProps);
|
|
}
|
|
}
|
|
|
|
/**======================
|
|
* Comment Actions
|
|
*========================**/
|
|
|
|
endCommenting(ev) {
|
|
if (
|
|
this.state.commenting &&
|
|
(
|
|
!ev.target.closest(`.o_knowledge_comment_box[data-id="${this.props.knowledgeThreadId}"]`) &&
|
|
!(
|
|
this.props.isCreationMode &&
|
|
ev.target.closest('.o_knowledge_comment_box') &&
|
|
!ev.target.closest('.o_knowledge_comment_box').dataset.id
|
|
)
|
|
) &&
|
|
(
|
|
!ev.target.closest(`.knowledge-thread-comment[data-id="${this.props.knowledgeThreadId}"]`) &&
|
|
!(
|
|
this.props.isCreationMode && ev.target.closest('.knowledge-thread-comment') &&
|
|
!ev.target.closest('.knowledge-thread-comment').dataset.id
|
|
)
|
|
) &&
|
|
ev.target.closest('.o_knowledge_form_view') &&
|
|
!this.textInputContent
|
|
) {
|
|
this.state.commenting = false;
|
|
this.highlightComment(false);
|
|
this.targetRef.el.classList.remove('commenting', 'focused-comment');
|
|
this.targetRef.el.querySelector('a[data-type="cancel"]')?.click();
|
|
if (!this.state.thread?.messages.length) {
|
|
if (!this.props.knowledgeThreadId) {
|
|
this.threadService.clearComposer(this.state.thread.composer);
|
|
}
|
|
this.props.destroyComment(this.state.knowledgeThreadId, this.mainAnchor);
|
|
}
|
|
}
|
|
}
|
|
|
|
showAnchors() {
|
|
this.mainAnchor?.scrollIntoView({
|
|
behavior: 'smooth',
|
|
block: 'center'
|
|
});
|
|
this.highlightComment(true);
|
|
window.setTimeout(() => this.highlightComment(false), 500);
|
|
}
|
|
|
|
startCommenting(ev) {
|
|
if (
|
|
(
|
|
ev.target.closest(`.comment[data-id="${this.state.knowledgeThreadId}"]`) ||
|
|
ev.target.closest(`.o_knowledge_comment_box[data-id="${this.state.knowledgeThreadId}"]`)
|
|
) &&
|
|
!ev.target.closest('.o-mail-Message-actions, .o-mail-Message-content .o-mail-Composer, .o-mail-MessageReaction, .o-mail-Composer ~ span a')
|
|
) {
|
|
if (!this.state.smallUI) {
|
|
this.autoFocusTextarea = true;
|
|
}
|
|
this.state.commenting = true;
|
|
if (!this.state.isResolved && this.state.smallUI && !this.props.forceFullSize) {
|
|
this.openPopover({target: this.targetRef.el});
|
|
}
|
|
this.highlightComment(true);
|
|
}
|
|
}
|
|
|
|
/**========================================================================
|
|
* Resolution State Thread
|
|
*========================================================================**/
|
|
|
|
highlightComment(isHighlighted) {
|
|
if (!this.props.forceFullSize) {
|
|
if (isHighlighted || this.state.commenting) {
|
|
this.targetRef.el?.classList.add('focused-comment');
|
|
} else {
|
|
this.targetRef.el?.classList.remove('focused-comment');
|
|
}
|
|
}
|
|
if (this.anchors) {
|
|
for (const anchor of this.anchors) {
|
|
if (isHighlighted || this.state.commenting) {
|
|
anchor.classList.add('focused-comment');
|
|
} else {
|
|
anchor.classList.remove('focused-comment');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* This function enables the user to resolve a thread. A resolved thread is a thread where the
|
|
* user considers that the discussion does not need to go any further, thus closing it and making
|
|
* its box disappear. These closed discussions can still be found in the panel if need be.
|
|
* @returns
|
|
*/
|
|
async resolveComment() {
|
|
const statusChanged = await this.props.changeCommentResolvedState(this.state.knowledgeThreadId, true);
|
|
if (!statusChanged) {
|
|
return;
|
|
}
|
|
this.state.isResolved = true;
|
|
if (this.props.forceFullSize) {
|
|
this.messages = await this.threadService.fetchNewMessages(this.state.thread);
|
|
}
|
|
}
|
|
/**
|
|
* This function enables the user to unresolve a thread making it again visible to other users.
|
|
* @returns
|
|
*/
|
|
async unresolveComment() {
|
|
const statusChanged = await this.props.changeCommentResolvedState(this.state.knowledgeThreadId, false);
|
|
if (!statusChanged) {
|
|
return;
|
|
}
|
|
this.state.isResolved = false;
|
|
if (this.props.forceFullSize) {
|
|
this.messages = await this.threadService.fetchNewMessages(this.state.thread);
|
|
}
|
|
}
|
|
|
|
/**======================
|
|
* Comment Creation
|
|
*========================**/
|
|
|
|
async insertNewThread(value, postData) {
|
|
if (!value) {
|
|
return;
|
|
}
|
|
const [id] = await this.orm.create(
|
|
'knowledge.article.thread',
|
|
[{ article_id: this.props.record.resId }],
|
|
{}
|
|
);
|
|
this.threadService.clearComposer(this.state.thread.composer);
|
|
this.state.thread = this.threadService.getThread('knowledge.article.thread', id);
|
|
this.threadService.post(this.state.thread, value, postData);
|
|
for (const anchor of this.anchors) {
|
|
anchor.dataset.id = id;
|
|
}
|
|
this.highlightComment(false);
|
|
await this.props.insertNewThread(id, this.state.thread);
|
|
this.state.updating = true;
|
|
}
|
|
}
|