diff --git a/packages/client/app/graphql/chat.queries.js b/packages/client/app/graphql/chat.queries.js index 9c7e9f3b1c0571a944ece7fa542e26af4ad8b99d..4ecc76a73ded9a6219a0280f5d9e02db864728bd 100644 --- a/packages/client/app/graphql/chat.queries.js +++ b/packages/client/app/graphql/chat.queries.js @@ -34,6 +34,34 @@ export const GET_CHAT_THREAD = gql` } ` +export const FILTER_CHAT_THREADS = gql` + query ChatThreads($where: CreateChatThreadInput) { + chatThreads(where: $where) { + result { + id + created + updated + chatType + relatedObjectId + messages { + id + content + created + user { + id + displayName + } + mentions + attachments { + name + url + } + } + } + } + } +` + export const SEND_MESSAGE = gql` mutation SendMessage($input: SendChatMessageInput!) { sendMessage(input: $input) { diff --git a/packages/client/app/graphql/complexItemSet.queries.js b/packages/client/app/graphql/complexItemSet.queries.js index c82e466427958f745d71beb2a5eb416a0b5e6dcc..e77ee63aa79f4552cc5a44959558804b85222764 100644 --- a/packages/client/app/graphql/complexItemSet.queries.js +++ b/packages/client/app/graphql/complexItemSet.queries.js @@ -67,6 +67,7 @@ export const GET_COMPLEX_ITEM_SET = gql` underReview inProduction published + unpublished topics { topic diff --git a/packages/client/app/graphql/question.queries.js b/packages/client/app/graphql/question.queries.js index 5a648f9303306ee7cd8293cbc0e424ffd8a7798e..3c87b4097fe2b88f3e5a5feb714d45e01c708884 100644 --- a/packages/client/app/graphql/question.queries.js +++ b/packages/client/app/graphql/question.queries.js @@ -369,8 +369,11 @@ export const GET_PRODUCTION_CHAT_PARTICIPANTS = gql` ` export const GET_REVIEWER_CHAT_PARTICIPANTS = gql` - query GetReviewerChatParticipants($id: ID!) { - getReviewerChatParticipants(id: $id) { + query GetReviewerChatParticipants($questionId: ID!, $reviewerId: ID!) { + getReviewerChatParticipants( + questionId: $questionId + reviewerId: $reviewerId + ) { id display: displayName role diff --git a/packages/client/app/pages/Question.page.js b/packages/client/app/pages/Question.page.js index 1fb6ca95b5e6b75458d14b4041311947e34316ed..d0c88069548249313c2bb4dc7b97b9d90d631303 100644 --- a/packages/client/app/pages/Question.page.js +++ b/packages/client/app/pages/Question.page.js @@ -10,8 +10,7 @@ import { import debounce from 'lodash/debounce' // import { questionDataTransformer, questionDataMapper } from '../utilities' -import { serverUrl } from '@coko/client' - +import { serverUrl, uuid } from '@coko/client' import { Question, Result, VisuallyHiddenElement } from 'ui' import { @@ -54,6 +53,7 @@ import { CHANGE_AMOUNT_OF_REVIEWERS, CHANGE_REVIEWER_AUTOMATION_STATUS, SUBMIT_REPORT, + FILTER_CHAT_THREADS, } from '../graphql' import { useMetadata, @@ -219,6 +219,8 @@ const QuestionPage = props => { const { metadata } = useMetadata() const requestedTab = window.location.hash.substring(1) + const [selectedReviewerId, setSelectedReviewerId] = useState(uuid()) + const [reviewerChatThread, setReviewerChatThread] = useState() const { data: { question } = {}, @@ -253,9 +255,10 @@ const QuestionPage = props => { const { data: { getReviewerChatParticipants: reviewerChatParticipants } = {}, } = useQuery(GET_REVIEWER_CHAT_PARTICIPANTS, { - skip: !question?.versions[0]?.underReview, + skip: !question?.versions[0]?.underReview || !selectedReviewerId, variables: { - id, + questionId: id, + reviewerId: selectedReviewerId, }, }) @@ -348,16 +351,20 @@ const QuestionPage = props => { }, ) - const { - data: { chatThread: reviewerChatThread } = {}, - loading: reviewerChatLoading, - } = useQuery(GET_CHAT_THREAD, { - skip: !question?.reviewerChatThreadId, + useQuery(GET_CHAT_THREAD, { + skip: !question?.reviewerChatThreadId || question?.versions[0].underReview, variables: { id: question?.reviewerChatThreadId, }, + onCompleted: ({ chatThread: reviewerChat }) => { + setSelectedReviewerId(currentUser.id) + setReviewerChatMessages(reviewerChat?.messages) + setReviewerChatThread(reviewerChat) + }, }) + const [getReviewerChatThread] = useLazyQuery(FILTER_CHAT_THREADS) + useSubscription(MESSAGE_CREATED_SUBSCRIPTION, { skip: !authorChatThread?.id, variables: { chatThreadId: authorChatThread?.id }, @@ -484,11 +491,11 @@ const QuestionPage = props => { setProductionChatMessages(productionChatThread.messages) } }, [productionChatThread]) - useEffect(() => { - if (reviewerChatThread?.messages) { - setReviewerChatMessages(reviewerChatThread.messages) - } - }, [reviewerChatThread]) + // useEffect(() => { + // if (reviewerChatThread?.messages) { + // setReviewerChatMessages(reviewerChatThread.messages) + // } + // }, [reviewerChatThread]) /* setup Prev/Next question functions */ // read state from location to get filter values, if any @@ -561,9 +568,9 @@ const QuestionPage = props => { createChat('productionChat') } - if (version?.underReview && !question?.reviewerChatThreadId) { - createChat('reviewerChat') - } + // if (version?.underReview && !question?.reviewerChatThreadId) { + // createChat('reviewerChat') + // } }, [question, version]) // declare lazy query to be called when no `relatedQuestionsIds` from previous state @@ -649,8 +656,10 @@ const QuestionPage = props => { }, { query: GET_REVIEWER_CHAT_PARTICIPANTS, + skip: !question?.versions[0]?.underReview || !selectedReviewerId, variables: { - id, + questionId: id, + reviewerId: selectedReviewerId, }, }, ], @@ -1108,9 +1117,10 @@ const QuestionPage = props => { }) break case 'reviewerChat': - cancelEmailNotification({ - variables: { chatThreadId: reviewerChatThread?.id }, - }) + reviewerChatThread?.id && + cancelEmailNotification({ + variables: { chatThreadId: reviewerChatThread?.id }, + }) break default: break @@ -1142,6 +1152,25 @@ const QuestionPage = props => { return sendMessage(mutationData) } + const handleSelectReviewer = async reviewerId => { + setSelectedReviewerId(reviewerId) + + const variables = { + where: { + relatedObjectId: question?.id, + chatType: `reviewerChat-${reviewerId}`, + }, + } + + // this query doesn't work as expected, needs to be fixed in coko server + const threads = await getReviewerChatThread({ variables }) + + const reviewerChat = threads?.data.chatThreads.result[0] + + setReviewerChatMessages(reviewerChat?.messages) + setReviewerChatThread(reviewerChat) + } + const onSendAuthorChatMessage = async (content, mentions, attachments) => { return handleSendChatMessage( content, @@ -1169,7 +1198,7 @@ const QuestionPage = props => { content, mentions, attachments, - question?.reviewerChatThreadId, + reviewerChatThread?.id, ) } @@ -1340,6 +1369,17 @@ const QuestionPage = props => { } // #endregion handlers + useEffect(() => { + if ( + question && + (!question?.reviewerChatThreadId || isUnderReview) && + isReviewer && + isUnderReview + ) { + handleSelectReviewer(currentUser.id) + } + }, [isReviewer, isUnderReview, question]) + if (error) { return ( <Result @@ -1392,7 +1432,7 @@ const QuestionPage = props => { canCreateNewVersion={isAdmin || isEditor} canPublish={isEditor || isHandlingEditor || isAdmin} canUnpublish={isAdmin || isEditor} - chatLoading={chatLoading || reviewerChatLoading} + chatLoading={chatLoading} complexItemSetId={version?.complexItemSetId} complexItemSetOptions={complexItemSetOptions} complexSetEditLink={ @@ -1411,6 +1451,9 @@ const QuestionPage = props => { facultyView={testMode || (isReviewer && isUnderReview)} handlingEditors={handlingEditors?.result || []} hasDeletedAuthor={!!question?.deletedAuthorName} + hasGeneralReviewerChatId={ + !!question?.reviewerChatThreadId && !isUnderReview + } initialMetadataValues={metadataApiToUi( version, testMode || (isReviewer && isUnderReview), @@ -1473,6 +1516,7 @@ const QuestionPage = props => { onReviewerTableChange={handleReviewerTableChange} onRevokeReviewerInvitation={handleRevokeReviewerInvitation} onSearchHE={handleSearchHE} + onSelectReviewer={handleSelectReviewer} onSendAuthorChatMessage={onSendAuthorChatMessage} onSendProductionChatMessage={onSendProductionChatMessage} onSendReviewerChatMessage={onSendReviewerChatMessage} diff --git a/packages/client/app/routes.js b/packages/client/app/routes.js index 7c80e997d2a95427e78b32b19e3efe516bf98e92..3ac2d2ccc2a14a4103d3c557bb9379460dcb713a 100644 --- a/packages/client/app/routes.js +++ b/packages/client/app/routes.js @@ -293,11 +293,10 @@ const SiteHeader = () => { const logout = () => { setCurrentUser(null) client.cache.reset() + localStorage.clear() - localStorage.removeItem('token') - localStorage.removeItem('dashboardLastUsedTab') - - history.push('/login') + // refresh to unmount notification provider + window.location.href = '/login' } const isAdmin = hasGlobalRole(currentUser, 'admin') diff --git a/packages/client/app/ui/chat/ChatThread.js b/packages/client/app/ui/chat/ChatThread.js index 4a93d289b898f05cc478eac0bde1470c20e779a8..bb2d509cdfda3d7671b629f92d2883c36eb94ea2 100644 --- a/packages/client/app/ui/chat/ChatThread.js +++ b/packages/client/app/ui/chat/ChatThread.js @@ -33,6 +33,7 @@ const ChatThread = props => { onFetchMore, onSendMessage, infiniteScroll, + inputPlaceholder, ...rest } = props @@ -95,7 +96,7 @@ const ChatThread = props => { aria-label="Write a message" onSend={onSendMessage} participants={participants} - placeholder="Write a message" + placeholder={inputPlaceholder || 'Write a message'} type="text" /> {announcementText && ( @@ -122,6 +123,7 @@ ChatThread.propTypes = { role: PropTypes.string, }), ), + inputPlaceholder: PropTypes.string, } ChatThread.defaultProps = { @@ -133,6 +135,7 @@ ChatThread.defaultProps = { onSendMessage: () => {}, participants: [], infiniteScroll: false, + inputPlaceholder: null, } export default ChatThread diff --git a/packages/client/app/ui/notifications/MentionsItem.js b/packages/client/app/ui/notifications/MentionsItem.js index 110b75875a4c8a5496099300d802183e92ea6fda..1cb3f14931e5251f340026872f24ea49bc5ff856 100644 --- a/packages/client/app/ui/notifications/MentionsItem.js +++ b/packages/client/app/ui/notifications/MentionsItem.js @@ -231,7 +231,17 @@ const MentionsItem = ({ item, markAs }) => { {itemLink ? ( <> <Link to={itemLink}>Go to Item</Link> - <Link onClick={() => markAs(true, [id])} to={chatLink}> + <Link + onClick={() => markAs(true, [id])} + to={ + chatLink.indexOf('#reviewerChat') > -1 + ? chatLink.substring( + 0, + chatLink.indexOf('#reviewerChat') + 13, + ) + : chatLink + } + > Go to Chat </Link> </> diff --git a/packages/client/app/ui/question/Question.js b/packages/client/app/ui/question/Question.js index 5b8fe88328c00e9dc8e5f9ef1ba295b0cb4e246f..1baccb5de615a5619c1149ff96836c1c828cf47b 100644 --- a/packages/client/app/ui/question/Question.js +++ b/packages/client/app/ui/question/Question.js @@ -39,6 +39,7 @@ import AssignAuthorButton from './AssignAuthorButton' import ReviewerRejectButton from './ReviewerRejectButton' import ReviewerAcceptButton from './ReviewerAcceptButton' import ReviewerSubmitButton from './ReviewerSubmitButton' +import ReviewerChats from './ReviewerChats' import { AssignReviewers } from '../assignReviewers' const ModalContext = React.createContext({ agree: false, setAgree: () => {} }) @@ -473,6 +474,8 @@ const Question = props => { showReviewerChatTab, showAssignReviewers, hasDeletedAuthor, + onSelectReviewer, + hasGeneralReviewerChatId, } = props const [modal, contextHolder] = Modal.useModal() @@ -1568,6 +1571,209 @@ const Question = props => { } } + const tabItems = [ + { + label: QuestionTab, + key: 'editor', + children: ( + <> + {isRejected && ( + <Ribbon status="error"> + This item has been rejected by the editors. + </Ribbon> + )} + {isUnpublished && ( + <Ribbon status="error"> + This item has been unpublished by the editors. + {hasDeletedAuthor && + ` The author of this item has been deleted. Assign a new author to be able to edit.`} + </Ribbon> + )} + {reviewInviteStatus === REVIEWER_STATUSES.revoked && ( + <Ribbon status="error"> + Invitation to review this item has been revoked. + </Ribbon> + )} + {reviewInviteStatus === REVIEWER_STATUSES.rejected && ( + <Ribbon status="error"> + You have rejected the invitation to review this item. + </Ribbon> + )} + {isArchived && ( + <Ribbon status="error">This item has been archived.</Ribbon> + )} + <PanelWrapper + condition={false} + editor={ + <QuestionEditor + complexItemSetId={complexItemSetId} + complexSetEditLink={complexSetEditLink} + content={editorContent} + innerRef={waxRef} + layout={preview || reviewerView ? TestModeLayout : HhmiLayout} + leadingContent={leadingContent} + onContentChange={handleQuestionContentChange} + onImageUpload={onImageUpload} + onSubmitReport={onSubmitReport} + published={isPublished} + readOnly={ + readOnly || preview || !selectedQuestionType // + } + refreshEditorContent={refreshEditorContent} + selectedQuestionType={selectedQuestionType} + showDialog={showDialog} + withFeedback={ + !(preview || reviewerView) || (showMetadata && facultyView) + } + /> + } + metadata={ + <> + <StyledMetadata + complexItemSetOptions={complexItemSetOptions} + editorView={editorView} + initialValues={initialMetadataValues} + innerRef={formRef} + metadata={metadata} + onAutoSave={handleMetadataAutoSave} + onFormFinish={onFormFinish} + presentationMode={facultyView} + readOnly={readOnly} + resources={resources} + selectedQuestionType={selectedQuestionType?.metadataValue} + showTopicAndSubtopicFields={ + isInProduction || isPublished || isUnpublished + } + /> + <SkipToTop + href="#question-actions" + onClick={e => { + e.preventDefault() + document.getElementById('question-actions').focus() + }} + > + {skipButtonText()} + </SkipToTop> + </> + } + showMetadata={showMetadata && (!preview || facultyView)} + /> + <VisuallyHiddenElement as="div"> + {imageLongDescs.map(longDesc => ( + <p id={longDesc.id}>{longDesc.content}</p> + ))} + </VisuallyHiddenElement> + </> + ), + }, + showAuthorChatTab && { + label: AuthorChatTab, + key: 'authorChat', + children: ( + <ChatThread + announcementText={announcementText} + hasMore={hasMoreMessages} + isActive={activeKey === 'authorChat'} + messages={authorChatMessages} + onFetchMore={onFetchMoreMessages} + onSendMessage={onSendAuthorChatMessage} + participants={authorChatParticipants} + /> + ), + }, + showProductionChatTab && { + label: ProductionAssignmentsTab, + key: 'productionChat', + children: ( + <ChatThread + isActive={activeKey === 'productionChat'} + messages={productionChatMessages} + onSendMessage={onSendProductionChatMessage} + participants={productionChatParticipants} + /> + ), + }, + showReviewerChatTab && { + label: ReviewerChatTab, + key: 'reviewerChat', + children: + reviewerView || hasGeneralReviewerChatId ? ( + <ChatThread + hasMore={hasMoreMessages} + isActive={activeKey === 'reviewerChat'} + messages={reviewerChatMessages} + onFetchMore={onFetchMoreMessages} + onSendMessage={onSendReviewerChatMessage} + participants={reviewerChatParticipants} + /> + ) : ( + <ReviewerChats + hasMore={hasMoreMessages} + isActive={activeKey === 'reviewerChat'} + messages={reviewerChatMessages} + onFetchMore={onFetchMoreMessages} + onSelectReviewer={onSelectReviewer} + onSendMessage={onSendReviewerChatMessage} + participants={reviewerChatParticipants} + reviewers={reviewerPool.filter(r => r.acceptedInvitation)} + /> + ), + }, + showAssignReviewers && { + label: AssignReviewersTab, + key: 'assignReviewers', + children: ( + <AssignReviewers + amountOfReviewers={amountOfReviewers} + automate={automateReviewerInvites} + canInviteMore={!!findAvailableReviewerSlots()} + onAddReviewers={onAddReviewers} + onAmountOfReviewersChange={onChangeAmountOfReviewers} + onAutomationChange={handleReviewerInviteAutomationChange} + onClickInvite={handleClickInviteReviewer} + onClickRemoveRow={onRemoveReviewerRow} + onClickRevokeInvitation={handleRevokeReviewerInvite} + onSearch={onReviewerSearch} + onTableChange={onReviewerTableChange} + reviewerPool={reviewerPool} + searchPlaceholder="Search by reviewer name or relevant topic" + /> + ), + }, + ] + + useEffect(() => { + if ( + showAuthorChatTab !== null && + showProductionChatTab !== null && + showReviewerChatTab !== null && + showAssignReviewers !== null + ) { + switch (activeKey) { + case 'authorChat': + !showAuthorChatTab && handleTabChange('editor') + break + case 'reviewerChat': + !showReviewerChatTab && handleTabChange('editor') + break + case 'productionChat': + !showProductionChatTab && handleTabChange('editor') + break + case 'assignReviewers': + !showAssignReviewers && handleTabChange('editor') + break + default: + handleTabChange('editor') + break + } + } + }, [ + showAuthorChatTab, + showProductionChatTab, + showReviewerChatTab, + showAssignReviewers, + ]) + return ( <ModalContext.Provider value={contextValue}> <Wrapper> @@ -1575,175 +1781,7 @@ const Question = props => { <StyledTabs $activebg="#fff" activeKey={activeKey} - items={[ - { - label: QuestionTab, - key: 'editor', - children: ( - <> - {isRejected && ( - <Ribbon status="error"> - This item has been rejected by the editors. - </Ribbon> - )} - {isUnpublished && ( - <Ribbon status="error"> - This item has been unpublished by the editors. - {hasDeletedAuthor && - ` The author of this item has been deleted. Assign a new author to be able to edit.`} - </Ribbon> - )} - {reviewInviteStatus === REVIEWER_STATUSES.revoked && ( - <Ribbon status="error"> - Invitation to review this item has been revoked. - </Ribbon> - )} - {reviewInviteStatus === REVIEWER_STATUSES.rejected && ( - <Ribbon status="error"> - You have rejected the invitation to review this item. - </Ribbon> - )} - {isArchived && ( - <Ribbon status="error"> - This item has been archived. - </Ribbon> - )} - <PanelWrapper - condition={false} - editor={ - <QuestionEditor - complexItemSetId={complexItemSetId} - complexSetEditLink={complexSetEditLink} - content={editorContent} - innerRef={waxRef} - layout={ - preview || reviewerView - ? TestModeLayout - : HhmiLayout - } - leadingContent={leadingContent} - onContentChange={handleQuestionContentChange} - onImageUpload={onImageUpload} - onSubmitReport={onSubmitReport} - published={isPublished} - readOnly={ - readOnly || preview || !selectedQuestionType // - } - refreshEditorContent={refreshEditorContent} - selectedQuestionType={selectedQuestionType} - showDialog={showDialog} - withFeedback={ - !(preview || reviewerView) || - (showMetadata && facultyView) - } - /> - } - metadata={ - <> - <StyledMetadata - complexItemSetOptions={complexItemSetOptions} - editorView={editorView} - initialValues={initialMetadataValues} - innerRef={formRef} - metadata={metadata} - onAutoSave={handleMetadataAutoSave} - onFormFinish={onFormFinish} - presentationMode={facultyView} - readOnly={readOnly} - resources={resources} - selectedQuestionType={ - selectedQuestionType?.metadataValue - } - showTopicAndSubtopicFields={ - isInProduction || isPublished || isUnpublished - } - /> - <SkipToTop - href="#question-actions" - onClick={e => { - e.preventDefault() - document - .getElementById('question-actions') - .focus() - }} - > - {skipButtonText()} - </SkipToTop> - </> - } - showMetadata={showMetadata && (!preview || facultyView)} - /> - <VisuallyHiddenElement as="div"> - {imageLongDescs.map(longDesc => ( - <p id={longDesc.id}>{longDesc.content}</p> - ))} - </VisuallyHiddenElement> - </> - ), - }, - showAuthorChatTab && { - label: AuthorChatTab, - key: 'authorChat', - children: ( - <ChatThread - announcementText={announcementText} - hasMore={hasMoreMessages} - isActive={activeKey === 'authorChat'} - messages={authorChatMessages} - onFetchMore={onFetchMoreMessages} - onSendMessage={onSendAuthorChatMessage} - participants={authorChatParticipants} - /> - ), - }, - showProductionChatTab && { - label: ProductionAssignmentsTab, - key: 'productionChat', - children: ( - <ChatThread - isActive={activeKey === 'productionChat'} - messages={productionChatMessages} - onSendMessage={onSendProductionChatMessage} - participants={productionChatParticipants} - /> - ), - }, - showReviewerChatTab && { - label: ReviewerChatTab, - key: 'reviewerChat', - children: ( - <ChatThread - hasMore={hasMoreMessages} - isActive={activeKey === 'reviewerChat'} - messages={reviewerChatMessages} - onFetchMore={onFetchMoreMessages} - onSendMessage={onSendReviewerChatMessage} - participants={reviewerChatParticipants} - /> - ), - }, - showAssignReviewers && { - label: AssignReviewersTab, - key: 'assignReviewers', - children: ( - <AssignReviewers - amountOfReviewers={amountOfReviewers} - automate={automateReviewerInvites} - canInviteMore={!!findAvailableReviewerSlots()} - onAddReviewers={onAddReviewers} - onAmountOfReviewersChange={onChangeAmountOfReviewers} - onAutomationChange={handleReviewerInviteAutomationChange} - onClickInvite={handleClickInviteReviewer} - onClickRemoveRow={onRemoveReviewerRow} - onClickRevokeInvitation={handleRevokeReviewerInvite} - onSearch={onReviewerSearch} - onTableChange={onReviewerTableChange} - reviewerPool={reviewerPool} - searchPlaceholder="Search by reviewer name or relevant topic" - /> - ), - }, - ]} + items={tabItems} onChange={handleTabChange} renderTabBar={(tabProps, DefaultTabBar) => { return facultyView && !reviewerView ? ( @@ -2145,6 +2183,8 @@ Question.propTypes = { showAssignReviewers: PropTypes.bool, hasDeletedAuthor: PropTypes.bool, + onSelectReviewer: PropTypes.func, + hasGeneralReviewerChatId: PropTypes.bool, } Question.defaultProps = { @@ -2238,12 +2278,14 @@ Question.defaultProps = { selectedQuestionType: null, onChangeTab: () => {}, - showAuthorChatTab: false, - showProductionChatTab: false, - showReviewerChatTab: false, - showAssignReviewers: false, + showAuthorChatTab: null, + showProductionChatTab: null, + showReviewerChatTab: null, + showAssignReviewers: null, hasDeletedAuthor: false, + onSelectReviewer: null, + hasGeneralReviewerChatId: false, } export default Question diff --git a/packages/client/app/ui/question/ReviewerChats.js b/packages/client/app/ui/question/ReviewerChats.js new file mode 100644 index 0000000000000000000000000000000000000000..7608dabc41cee078344a6f90bcc97d96d00b7650 --- /dev/null +++ b/packages/client/app/ui/question/ReviewerChats.js @@ -0,0 +1,111 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable react/prop-types */ +import React, { useEffect, useState } from 'react' +import styled from 'styled-components' +import { grid, th } from '@coko/client' +import { ChatThread } from '../chat' +import { Select } from '../common' + +const ReviewerChatHeader = styled.header` + align-items: center; + border-bottom: 1px solid ${th('colorBorder')}; + display: flex; + justify-content: flex-end; + padding: ${grid(1)} ${grid(3)}; +` + +const Wrapper = styled.section` + display: flex; + flex-direction: column; + height: 100%; + position: relative; +` + +const StyledSelect = styled(Select)` + width: 200px; +` + +const ReviewerSelectorWrapper = styled.div` + align-items: center; + display: flex; + gap: ${grid(2)}; +` + +const NoReviewer = styled.div` + display: grid; + height: 100%; + place-content: center; + + p { + text-align: center; + } +` + +const ReviewerChats = props => { + const { + hasMore, + isActive, + messages, + onFetchMore, + onSendMessage, + participants, + + reviewers, + onSelectReviewer, + } = props + + const [selectedReviewer, setSelectedReviewer] = useState() + + useEffect(() => { + if (selectedReviewer?.id) { + // load chat + onSelectReviewer(selectedReviewer.id) + } + }, [selectedReviewer]) + + const handleChangeReviewer = val => { + setSelectedReviewer(reviewers.find(r => r.id === val)) + } + + return ( + <Wrapper> + <ReviewerChatHeader> + <ReviewerSelectorWrapper> + <label htmlFor="selectReviewer">Chat with reviewer: </label> + <StyledSelect + id="selectReviewer" + onChange={handleChangeReviewer} + options={reviewers?.map(r => ({ + label: r.displayName, + value: r.id, + }))} + /> + </ReviewerSelectorWrapper> + </ReviewerChatHeader> + {selectedReviewer ? ( + <ChatThread + hasMore={hasMore} + inputPlaceholder={`Write to ${selectedReviewer.displayName}`} + isActive={isActive} + messages={messages} + onFetchMore={onFetchMore} + onSendMessage={onSendMessage} + participants={participants.map(p => + p.id === selectedReviewer?.id ? { ...p, role: 'reviewer' } : p, + )} + /> + ) : ( + <NoReviewer> + <div> + <p> + <strong>No reviewer selected</strong> + </p> + <p>Select a reviewer to chat</p> + </div> + </NoReviewer> + )} + </Wrapper> + ) +} + +export default ReviewerChats diff --git a/packages/server/api/question/question.graphql b/packages/server/api/question/question.graphql index e97aa110d28e070042f48ccd4e6ad2dd836f3d25..8f310312b0ec575f4fd242d257cb5100d0c16944 100644 --- a/packages/server/api/question/question.graphql +++ b/packages/server/api/question/question.graphql @@ -265,7 +265,7 @@ extend type Query { getQuestionsHandlingEditors(questionId: ID!): [User!]! getAuthorChatParticipants(id: ID!): [User!]! getProductionChatParticipants(id: ID!): [User!]! - getReviewerChatParticipants(id: ID!): [User!]! + getReviewerChatParticipants(questionId: ID!, reviewerId: ID!): [User!]! } extend type Mutation { diff --git a/packages/server/api/question/question.resolvers.js b/packages/server/api/question/question.resolvers.js index 56b3d3daca45cdc3cd4b9cccbf03b3e4ee176ab2..1522d14869814f3a565afab4176ff9adbe93c489 100644 --- a/packages/server/api/question/question.resolvers.js +++ b/packages/server/api/question/question.resolvers.js @@ -234,8 +234,11 @@ const getProductionChatParticipantsResolver = async (_, { id }) => { return getProductionChatParticipants(id) } -const getReviewerChatParticipantsResolver = async (_, { id }) => { - return getReviewerChatParticipants(id) +const getReviewerChatParticipantsResolver = async ( + _, + { questionId, reviewerId }, +) => { + return getReviewerChatParticipants(questionId, reviewerId) } const updateReviewerPoolResolver = async ( diff --git a/packages/server/controllers/__tests__/question.controllers.test.js b/packages/server/controllers/__tests__/question.controllers.test.js index 687cfbf1224307bcef64511343457764b9d25f65..7628ed63520a117addae3540238e7ca6ba3da6f8 100644 --- a/packages/server/controllers/__tests__/question.controllers.test.js +++ b/packages/server/controllers/__tests__/question.controllers.test.js @@ -958,7 +958,11 @@ describe('Question Controller', () => { reviewer2.id, ] - const participants = await getReviewerChatParticipants(question.id) + const participants = await getReviewerChatParticipants( + question.id, + reviewer1.id, + ) + const receivedParticipantIds = participants.map(p => p.id) expectedParticipantIds.forEach(participantId => diff --git a/packages/server/controllers/question.controllers.js b/packages/server/controllers/question.controllers.js index 7dbbf600bea8acfa14121f485b0753e5599b3eb9..42090114c92e253d99abf3301678102caf40eb3b 100644 --- a/packages/server/controllers/question.controllers.js +++ b/packages/server/controllers/question.controllers.js @@ -289,9 +289,7 @@ const getProductionChatParticipants = async questionId => { return participants.filter(p => p.id !== author?.id) } -const getReviewerChatParticipants = async questionId => { - const questionVersion = await QuestionVersion.findOne({ questionId }) - +const getReviewerChatParticipants = async (questionId, reviewerId) => { const query = User.query() .select( 'users.displayName', @@ -305,7 +303,7 @@ const getReviewerChatParticipants = async questionId => { .where('teams.role', EDITOR_TEAM.role) .orWhere(subquery => { subquery - .whereIn('teams.role', [AUTHOR_TEAM.role, HE_TEAM.role]) + .whereIn('teams.role', [HE_TEAM.role]) .whereExists( Team.query() .select(1) @@ -314,25 +312,15 @@ const getReviewerChatParticipants = async questionId => { .where('questions.id', questionId), ) }) - .orWhere(reviewerSubquery => { - reviewerSubquery - .where('teams.role', REVIEWER_TEAM.role) - .whereExists( - TeamMember.query() - .select(1) - .from('teams') - .whereRaw('team_members.team_id=teams.id') - .where('teams.object_id', questionVersion.id) - .where('team_members.status', REVIEWER_STATUSES.accepted), - ) - }) + .orWhere('users.id', reviewerId) .orderByRaw("CASE WHEN teams.role = 'handlingEditor' THEN 1 ELSE 0 END") const participants = await query - const author = participants.find(p => p.role === 'author') - - return participants.filter(p => p.id !== author?.id) + // filter out duplicated reviewer entry + return participants.filter( + (p, index, self) => index === self.findIndex(t => t.id === p.id), + ) } /** diff --git a/packages/server/controllers/team.controllers.js b/packages/server/controllers/team.controllers.js index e537ec762b56846180eb4224d07b0a328b78495a..7d783c492aa4ab184a5e029a33d219597433befc 100644 --- a/packages/server/controllers/team.controllers.js +++ b/packages/server/controllers/team.controllers.js @@ -1,4 +1,6 @@ const { logger, useTransaction, pubsubManager } = require('@coko/server') + +const { ChatThread } = require('@coko/server/src/models') const config = require('config') const { uniq } = require('lodash') @@ -512,6 +514,14 @@ const acceptOrRejectInvitation = async ( }, { trx }, ) + + await ChatThread.insert( + { + relatedObjectId: questionVersion.questionId, + chatType: `reviewerChat-${userId}`, + }, + { trx }, + ) } const notifier = new CokoNotifier() diff --git a/packages/server/models/complexItemSet/complexItemSet.model.js b/packages/server/models/complexItemSet/complexItemSet.model.js index 98043c09d896e6868244555f60d193fd29411c91..02fbf38766783e3f04035ffc90ccd98bc406a04a 100644 --- a/packages/server/models/complexItemSet/complexItemSet.model.js +++ b/packages/server/models/complexItemSet/complexItemSet.model.js @@ -114,6 +114,7 @@ class ComplexItemSet extends BaseModel { static async filterSetsForUser(userId, searchQuery, options) { // userId will be null if user is not logged in or only public sets were requested + // Does not apply, all pages require the user to log in if (!userId) { return ComplexItemSet.query().select('*').where('isPublished', true) } @@ -154,10 +155,14 @@ class ComplexItemSet extends BaseModel { query.select(selectFields) } else { query - .select(selectFields) - .where(builder => - builder.where('isPublished', true).orWhereIn('id', authoredSets), + .leftJoin( + 'question_versions', + 'question_versions.complexItemSetId', + 'complexItemSets.id', ) + .select([...selectFields, 'question_versions.published']) + .whereIn('complexItemSets.id', authoredSets) + .orWhere('question_versions.published', true) } if (searchQuery) {