Просмотр исходного кода

Merge pull request #5503 from weseek/feat/82575-89919-display-comment-in-search-page

imprv: display comment in search page
yuto-o 4 лет назад
Родитель
Сommit
4cadaa4242

+ 8 - 2
packages/app/src/client/app.jsx

@@ -120,9 +120,15 @@ Object.assign(componentMappings, {
 // additional definitions if data exists
 if (pageContainer.state.pageId != null) {
   Object.assign(componentMappings, {
+    // todo: replace PageComments by commonalizing PageComments and PageCommentList
     'page-comments-list': <PageComments />,
-    'page-comment-write': <CommentEditorLazyRenderer />,
-    'page-content-footer': <PageContentFooter />,
+    'page-comment-write': <CommentEditorLazyRenderer appContainer={appContainer} />,
+    'page-content-footer': <PageContentFooter
+      createdAt={new Date(pageContainer.state.createdAt)}
+      updatedAt={new Date(pageContainer.state.updatedAt)}
+      creator={pageContainer.state.creator}
+      revisionAuthor={pageContainer.state.revisionAuthor}
+    />,
 
     'recent-created-icon': <RecentlyCreatedIcon />,
   });

+ 0 - 31
packages/app/src/components/PageComment/CommentEditorLazyRenderer.jsx

@@ -1,31 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-
-import CommentEditor from './CommentEditor';
-
-const CommentEditorLazyRenderer = (props) => {
-
-  const growiRenderer = props.appContainer.getRenderer('comment');
-
-  return (
-    <CommentEditor
-      growiRenderer={growiRenderer}
-      replyTo={undefined}
-      isForNewComment
-    />
-  );
-};
-
-/**
- * Wrapper component for using unstated
- */
-const CommentEditorLazyRendererWrapper = withUnstatedContainers(CommentEditorLazyRenderer, [AppContainer]);
-
-CommentEditorLazyRenderer.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-};
-
-export default CommentEditorLazyRendererWrapper;

+ 24 - 0
packages/app/src/components/PageComment/CommentEditorLazyRenderer.tsx

@@ -0,0 +1,24 @@
+import React, { FC } from 'react';
+
+import AppContainer from '~/client/services/AppContainer';
+
+import CommentEditor from './CommentEditor';
+
+type Props = {
+  appContainer: AppContainer,
+}
+
+const CommentEditorLazyRenderer:FC<Props> = (props:Props):JSX.Element => {
+
+  const growiRenderer = props.appContainer.getRenderer('comment');
+
+  return (
+    <CommentEditor
+      growiRenderer={growiRenderer}
+      replyTo={undefined}
+      isForNewComment
+    />
+  );
+};
+
+export default CommentEditorLazyRenderer;

+ 197 - 0
packages/app/src/components/PageCommentList.tsx

@@ -0,0 +1,197 @@
+import React, {
+  FC, useEffect, useState, useMemo, memo, useCallback,
+} from 'react';
+
+import { UserPicture } from '@growi/ui';
+import AppContainer from '~/client/services/AppContainer';
+
+import RevisionBody from './Page/RevisionBody';
+import Username from './User/Username';
+import FormattedDistanceDate from './FormattedDistanceDate';
+import HistoryIcon from './Icons/HistoryIcon';
+
+import { ICommentHasId, ICommentHasIdList } from '../interfaces/comment';
+
+import { useSWRxPageComment } from '../stores/comment';
+
+import MathJaxConfigurer from '~/client/util/markdown-it/mathjax';
+
+
+type Props = {
+  appContainer: AppContainer,
+  pageId: string,
+  highlightKeywords?:string[],
+}
+
+// todo: update this component to shared PageComment component
+const PageCommentList:FC<Props> = memo((props:Props):JSX.Element => {
+
+  const { appContainer, pageId, highlightKeywords } = props;
+
+  const { data: comments, mutate } = useSWRxPageComment(pageId);
+  const [formatedComments, setFormatedComments] = useState<ICommentHasIdList | null>(null);
+
+  const commentsFromOldest = useMemo(() => (formatedComments != null ? [...formatedComments].reverse() : null), [formatedComments]);
+  const commentsExceptReply: ICommentHasIdList | undefined = useMemo(
+    () => commentsFromOldest?.filter(comment => comment.replyTo == null), [commentsFromOldest],
+  );
+  const allReplies = {};
+
+  /**
+   * preprocess:
+   * parse, sanitize, convert markdown to html
+   */
+  const preprocessComment = useCallback(async(comment:string):Promise<string> => {
+
+    const { interceptorManager } = appContainer;
+    const growiRenderer = appContainer.getRenderer('comment');
+
+    const context: {markdown: string, parsedHTML: string} = { markdown: comment, parsedHTML: '' };
+
+    if (interceptorManager != null) {
+      await interceptorManager.process('preRenderComment', context);
+      await interceptorManager.process('prePreProcess', context);
+      context.markdown = await growiRenderer.preProcess(context.markdown);
+      await interceptorManager.process('postPreProcess', context);
+      context.parsedHTML = await growiRenderer.process(context.markdown);
+      await interceptorManager.process('prePostProcess', context);
+      context.parsedHTML = await growiRenderer.postProcess(context.parsedHTML);
+      await interceptorManager.process('postPostProcess', context);
+      await interceptorManager.process('preRenderCommentHtml', context);
+      await interceptorManager.process('postRenderCommentHtml', context);
+    }
+
+    return context.parsedHTML;
+
+  }, [appContainer]);
+
+  const highlightComment = useCallback((comment: string):string => {
+    if (highlightKeywords == null) return comment;
+
+    let highlightedComment = '';
+    highlightKeywords.forEach((highlightKeyword) => {
+      highlightedComment = comment.replaceAll(highlightKeyword, '<em class="highlighted-keyword">$&</em>');
+    });
+    return highlightedComment;
+  }, [highlightKeywords]);
+
+  useEffect(() => { mutate() }, [pageId, mutate]);
+
+  useEffect(() => {
+    const formatAndHighlightComments = async() => {
+
+      if (comments != null) {
+        const preprocessedCommentList: string[] = await Promise.all(comments.map((comment) => {
+          const highlightedComment: string = highlightComment(comment.comment);
+          return preprocessComment(highlightedComment);
+        }));
+        const preprocessedComments: ICommentHasIdList = comments.map((comment, index) => {
+          return { ...comment, comment: preprocessedCommentList[index] };
+        });
+        setFormatedComments(preprocessedComments);
+      }
+
+    };
+
+    formatAndHighlightComments();
+
+  }, [comments, highlightComment, preprocessComment]);
+
+  if (commentsFromOldest != null) {
+    commentsFromOldest.forEach((comment) => {
+      if (comment.replyTo != null) {
+        allReplies[comment.replyTo] = allReplies[comment.replyTo] == null ? [comment] : [...allReplies[comment.replyTo], comment];
+      }
+    });
+  }
+
+  const generateMarkdownBody = (comment: string): JSX.Element => {
+    const isMathJaxEnabled: boolean = (new MathJaxConfigurer(appContainer)).isEnabled;
+    return (
+      <RevisionBody
+        html={comment}
+        isMathJaxEnabled={isMathJaxEnabled}
+        renderMathJaxOnInit
+        additionalClassName="comment"
+      />
+    );
+  };
+
+  const generateBodyFromPlainText = (comment: string): JSX.Element => {
+    return <span style={{ whiteSpace: 'pre-wrap' }}>{comment}</span>;
+  };
+
+  const generateCommentInnerElement = (comment: ICommentHasId) => {
+    const commentBody: string = comment.comment;
+    const formatedCommentBody = comment.isMarkdown ? generateMarkdownBody(commentBody) : generateBodyFromPlainText(commentBody);
+
+    return (
+      <>
+        <div className="flex-shrink-0">
+          <UserPicture user={comment.creator} size="md" noLink noTooltip />
+        </div>
+        <div className="flex-grow-1 ml-3">
+          <div className="d-flex">
+            <div className="flex-shrink-0">
+              <Username user={comment.creator} />
+            </div>
+            <div className="flex-grow-1 ml-3 text-right">
+              <div className="page-comment-meta">
+                <HistoryIcon />
+                <a href={`#${comment._id}`}>
+                  <FormattedDistanceDate id={comment._id} date={comment.createdAt} />
+                </a>
+              </div>
+            </div>
+          </div>
+          <div className="page-comment-body">{formatedCommentBody}</div>
+        </div>
+      </>
+    );
+  };
+
+  const generateAllRepliesElement = (replyComments: ICommentHasIdList) => {
+    return (
+      replyComments.map((comment: ICommentHasId, index: number) => {
+        const lastIndex: number = replyComments.length - 1;
+        const isLastIndex: boolean = index === lastIndex;
+
+        return (
+          <div key={comment._id} className={`d-flex ml-4 ${isLastIndex ? 'mb-5' : 'mb-3'}`}>
+            {generateCommentInnerElement(comment)}
+          </div>
+        );
+
+      })
+    );
+  };
+
+
+  if (commentsFromOldest == null || commentsExceptReply == null) return <></>;
+
+  return (
+    <div className="comment-list border border-top mt-5 px-2">
+      <h2 className="my-3 text-center"><i className="icon-fw icon-bubbles"></i>Comments</h2>
+
+      { commentsExceptReply.map((comment) => {
+
+        const hasReply: boolean = Object.keys(allReplies).includes(comment._id);
+
+        return (
+          <div key={comment._id} className="age-comment-main">
+            {/* display comment */}
+            <div className={`d-flex ${hasReply ? 'mb-3' : 'mb-5'}`}>
+              {generateCommentInnerElement(comment)}
+            </div>
+            {/* display reply comment */}
+            {hasReply && generateAllRepliesElement(allReplies[comment._id])}
+          </div>
+        );
+      })}
+
+    </div>
+  );
+});
+
+
+export default PageCommentList;

+ 0 - 45
packages/app/src/components/PageContentFooter.jsx

@@ -1,45 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import AuthorInfo from './Navbar/AuthorInfo';
-
-import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
-import { withUnstatedContainers } from './UnstatedUtils';
-import { useCurrentCreatedAt, useCurrentUpdatedAt } from '~/stores/context';
-
-const PageContentFooter = (props) => {
-  const { pageContainer } = props;
-  const { data: createdAt } = useCurrentCreatedAt();
-  const { data: updatedAt } = useCurrentUpdatedAt();
-
-
-  const {
-    creator, revisionAuthor,
-  } = pageContainer.state;
-
-
-  return (
-    <div className="page-content-footer py-4 d-edit-none d-print-none">
-      <div className="grw-container-convertible">
-        <div className="page-meta">
-          <AuthorInfo user={creator} date={createdAt} mode="create" locate="footer" />
-          <AuthorInfo user={revisionAuthor} date={updatedAt} mode="update" locate="footer" />
-        </div>
-      </div>
-    </div>
-  );
-};
-
-/**
- * Wrapper component for using unstated
- */
-const PageContentFooterWrapper = withUnstatedContainers(PageContentFooter, [AppContainer, PageContainer]);
-
-
-PageContentFooter.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-};
-
-export default PageContentFooterWrapper;

+ 33 - 0
packages/app/src/components/PageContentFooter.tsx

@@ -0,0 +1,33 @@
+import React, { FC, memo } from 'react';
+
+import AuthorInfo from './Navbar/AuthorInfo';
+
+import { Ref } from '../interfaces/common';
+import { IUser } from '../interfaces/user';
+
+type Props = {
+  createdAt: Date,
+  updatedAt: Date,
+  creator: Ref<IUser>,
+  revisionAuthor: Ref<IUser>,
+}
+
+const PageContentFooter:FC<Props> = memo((props:Props):JSX.Element => {
+  const {
+    createdAt, updatedAt, creator, revisionAuthor,
+  } = props;
+
+  return (
+    <div className="page-content-footer py-4 d-edit-none d-print-none">
+      <div className="grw-container-convertible">
+        <div className="page-meta">
+          <AuthorInfo user={creator as IUser} date={createdAt} mode="create" locate="footer" />
+          <AuthorInfo user={revisionAuthor as IUser} date={updatedAt} mode="update" locate="footer" />
+        </div>
+      </div>
+    </div>
+  );
+});
+
+
+export default PageContentFooter;

+ 11 - 0
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -15,6 +15,9 @@ import { useDescendantsPageListForCurrentPathTermManager } from '~/stores/page';
 import { exportAsMarkdown } from '~/client/services/page-operation';
 import { toastSuccess } from '~/client/util/apiNotification';
 
+import PageContentFooter from '../PageContentFooter';
+import PageCommentList from '../PageCommentList';
+
 import RevisionLoader from '../Page/RevisionLoader';
 import AppContainer from '../../client/services/AppContainer';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
@@ -216,6 +219,14 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
           highlightKeywords={highlightKeywords}
           isRenderable
         />
+        <PageCommentList appContainer={appContainer} pageId={page._id} highlightKeywords={highlightKeywords} />
+        {/* todo: insert adding comment feature by CommentEditorLazyRenderer */}
+        <PageContentFooter
+          createdAt={new Date(pageWithMeta.data.createdAt)}
+          updatedAt={new Date(pageWithMeta.data.updatedAt)}
+          creator={pageWithMeta.data.creator}
+          revisionAuthor={pageWithMeta.data.lastUpdateUser}
+        />
       </div>
     </div>
   );

+ 20 - 0
packages/app/src/interfaces/comment.ts

@@ -0,0 +1,20 @@
+import { Nullable, Ref } from './common';
+import { IPage } from './page';
+import { IUser } from './user';
+import { IRevision } from './revision';
+import { HasObjectId } from './has-object-id';
+
+export type IComment = {
+  comment: string;
+  commentPosition: number,
+  isMarkdown: boolean,
+  replyTo: Nullable<string>,
+  createdAt: Date,
+  updatedAt: Date,
+  page: Ref<IPage>,
+  revision: Ref<IRevision>,
+  creator: IUser,
+};
+
+export type ICommentHasId = IComment & HasObjectId;
+export type ICommentHasIdList = ICommentHasId[];

+ 0 - 22
packages/app/src/server/models/obsolete-page.js

@@ -483,28 +483,6 @@ export const getPageSchema = (crowi) => {
     return await findListFromBuilderAndViewer(builder, currentUser, showAnyoneKnowsLink, opt);
   };
 
-  pageSchema.statics.findListByPageIds = async function(ids, option = {}, shouldIncludeEmpty = false) {
-    const User = crowi.model('User');
-
-    const opt = Object.assign({}, option);
-    const builder = new this.PageQueryBuilder(this.find({ _id: { $in: ids } }), shouldIncludeEmpty);
-
-    builder.addConditionToPagenate(opt.offset, opt.limit);
-
-    // count
-    const totalCount = await builder.query.exec('count');
-
-    // find
-    builder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
-    const pages = await builder.query.clone().exec('find');
-
-    const result = {
-      pages, totalCount, offset: opt.offset, limit: opt.limit,
-    };
-    return result;
-  };
-
-
   /**
    * find pages by PageQueryBuilder
    * @param {PageQueryBuilder} builder

+ 34 - 2
packages/app/src/server/service/search.ts

@@ -15,6 +15,8 @@ import PrivateLegacyPagesDelegator from './search-delegator/private-legacy-pages
 import { PageModel } from '../models/page';
 import { serializeUserSecurely } from '../models/serializers/user-serializer';
 
+import { ObjectIdLike } from '../interfaces/mongoose-utils';
+
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:service:search');
 
@@ -36,6 +38,31 @@ const normalizeQueryString = (_queryString: string): string => {
   return queryString;
 };
 
+const findPageListByIds = async(pageIds: ObjectIdLike[], crowi: any) => {
+
+  const Page = crowi.model('Page') as unknown as PageModel;
+  const User = crowi.model('User');
+
+  const builder = new Page.PageQueryBuilder(Page.find(({ _id: { $in: pageIds } })), false);
+
+  builder.addConditionToPagenate(undefined, undefined); // offset and limit are unnesessary
+
+  builder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL); // populate lastUpdateUser
+  builder.query = builder.query.populate({
+    path: 'creator',
+    select: User.USER_FIELDS_EXCEPT_CONFIDENTIAL,
+  });
+
+  const pages = await builder.query.clone().exec('find');
+  const totalCount = await builder.query.exec('count');
+
+  return {
+    pages,
+    totalCount,
+  };
+
+};
+
 class SearchService implements SearchQueryParser, SearchResolver {
 
   crowi!: any
@@ -368,13 +395,13 @@ class SearchService implements SearchQueryParser, SearchResolver {
     /*
      * Format ElasticSearch result
      */
-    const Page = this.crowi.model('Page') as unknown as PageModel;
     const User = this.crowi.model('User');
     const result = {} as IFormattedSearchResult;
 
     // get page data
     const pageIds = searchResult.data.map((page) => { return page._id });
-    const findPageResult = await Page.findListByPageIds(pageIds);
+
+    const findPageResult = await findPageListByIds(pageIds, this.crowi);
 
     // set meta data
     result.meta = searchResult.meta;
@@ -398,6 +425,11 @@ class SearchService implements SearchQueryParser, SearchResolver {
         pageData.lastUpdateUser = serializeUserSecurely(pageData.lastUpdateUser);
       }
 
+      // serialize creator
+      if (pageData.creator != null && pageData.creator instanceof User) {
+        pageData.creator = serializeUserSecurely(pageData.creator);
+      }
+
       // const data = searchResult.data.find((data) => {
       //   return pageData.id === data._id;
       // });

+ 19 - 0
packages/app/src/stores/comment.tsx

@@ -0,0 +1,19 @@
+import useSWR, { SWRResponse } from 'swr';
+
+import { apiGet } from '~/client/util/apiv1-client';
+
+import { ICommentHasIdList } from '../interfaces/comment';
+import { Nullable } from '../interfaces/common';
+
+type IResponseComment = {
+  comments: ICommentHasIdList,
+  ok: boolean,
+}
+
+export const useSWRxPageComment = (pageId: Nullable<string>): SWRResponse<ICommentHasIdList, Error> => {
+  const shouldFetch: boolean = pageId != null;
+  return useSWR(
+    shouldFetch ? '/comments.get' : null,
+    endpoint => apiGet(endpoint, { page_id: pageId }).then((response:IResponseComment) => response.comments),
+  );
+};