فهرست منبع

Merge branch 'master' into fix/89163-do-not-highlight-pages-with-empty-body

# Conflicts:
#	packages/app/src/components/SearchPage2/SearchPageBase.tsx
Shun Miyazawa 4 سال پیش
والد
کامیت
8075016b93

+ 3 - 7
packages/app/src/components/Page/RevisionRenderer.jsx

@@ -66,11 +66,7 @@ class LegacyRevisionRenderer extends React.PureComponent {
    * @param {string} body html strings
    * @param {string} body html strings
    * @param {string} keywords
    * @param {string} keywords
    */
    */
-  getHighlightedBody(body, _keywords) {
-    const keywords = Array.isArray(_keywords)
-      ? _keywords
-      : [_keywords];
-
+  getHighlightedBody(body, keywords) {
     const normalizedKeywordsArray = [];
     const normalizedKeywordsArray = [];
     // !!TODO!!: add test code refs: https://redmine.weseek.co.jp/issues/86841
     // !!TODO!!: add test code refs: https://redmine.weseek.co.jp/issues/86841
     // Separate keywords
     // Separate keywords
@@ -178,7 +174,7 @@ LegacyRevisionRenderer.propTypes = {
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   markdown: PropTypes.string.isRequired,
   markdown: PropTypes.string.isRequired,
   isRenderable: PropTypes.bool,
   isRenderable: PropTypes.bool,
-  highlightKeywords: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]),
+  highlightKeywords: PropTypes.arrayOf(PropTypes.string),
   additionalClassName: PropTypes.string,
   additionalClassName: PropTypes.string,
 };
 };
 
 
@@ -196,7 +192,7 @@ const RevisionRenderer = (props) => {
 RevisionRenderer.propTypes = {
 RevisionRenderer.propTypes = {
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   markdown: PropTypes.string.isRequired,
   markdown: PropTypes.string.isRequired,
-  highlightKeywords: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]),
+  highlightKeywords: PropTypes.arrayOf(PropTypes.string),
   additionalClassName: PropTypes.string,
   additionalClassName: PropTypes.string,
 };
 };
 
 

+ 1 - 1
packages/app/src/components/Page/TrashPageAlert.jsx

@@ -56,7 +56,7 @@ const TrashPageAlert = (props) => {
 
 
   function openPageDeleteModalHandler() {
   function openPageDeleteModalHandler() {
     const pageToDelete = {
     const pageToDelete = {
-      pageData: {
+      data: {
         _id: pageId,
         _id: pageId,
         revision: revisionId,
         revision: revisionId,
         path,
         path,

+ 1 - 0
packages/app/src/components/SearchPage.tsx

@@ -268,6 +268,7 @@ export const SearchPage = (props: Props): JSX.Element => {
       ref={searchPageBaseRef}
       ref={searchPageBaseRef}
       appContainer={appContainer}
       appContainer={appContainer}
       pages={data?.data}
       pages={data?.data}
+      searchingKeyword={keyword}
       onSelectedPagesByCheckboxesChanged={selectedPagesByCheckboxesChangedHandler}
       onSelectedPagesByCheckboxesChanged={selectedPagesByCheckboxesChangedHandler}
       // Components
       // Components
       searchControl={searchControl}
       searchControl={searchControl}

+ 10 - 9
packages/app/src/components/SearchPage2/SearchPageBase.tsx

@@ -17,6 +17,10 @@ import { SearchResultContent } from '../SearchPage/SearchResultContent';
 import { SearchResultList } from '../SearchPage/SearchResultList';
 import { SearchResultList } from '../SearchPage/SearchResultList';
 
 
 
 
+// https://regex101.com/r/brrkBu/1
+const highlightKeywordsSplitter = new RegExp('"[^"]+"|[^\u{20}\u{3000}]+', 'ug');
+
+
 export interface IReturnSelectedPageIds {
 export interface IReturnSelectedPageIds {
   getSelectedPageIds?: () => Set<string>,
   getSelectedPageIds?: () => Set<string>,
 }
 }
@@ -26,6 +30,7 @@ type Props = {
   appContainer: AppContainer,
   appContainer: AppContainer,
 
 
   pages?: IPageWithMeta<IPageSearchMeta>[],
   pages?: IPageWithMeta<IPageSearchMeta>[],
+  searchingKeyword?: string,
 
 
   forceHideMenuItems?: ForceHideMenuItems,
   forceHideMenuItems?: ForceHideMenuItems,
 
 
@@ -40,6 +45,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
   const {
   const {
     appContainer,
     appContainer,
     pages,
     pages,
+    searchingKeyword,
     forceHideMenuItems,
     forceHideMenuItems,
     onSelectedPagesByCheckboxesChanged,
     onSelectedPagesByCheckboxesChanged,
     searchControl, searchResultListHead, searchPager,
     searchControl, searchResultListHead, searchPager,
@@ -51,10 +57,6 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
   const { data: isSearchServiceConfigured } = useIsSearchServiceConfigured();
   const { data: isSearchServiceConfigured } = useIsSearchServiceConfigured();
   const { data: isSearchServiceReachable } = useIsSearchServiceReachable();
   const { data: isSearchServiceReachable } = useIsSearchServiceReachable();
 
 
-  // TODO get search keywords and split
-  // ref: RevisionRenderer
-  //   [...keywords.match(/"[^"]+"|[^\u{20}\u{3000}]+/ug)].forEach((keyword, i) => {
-  const [highlightKeywords, setHightlightKeywords] = useState<string>('');
   const [selectedPageIdsByCheckboxes] = useState<Set<string>>(new Set());
   const [selectedPageIdsByCheckboxes] = useState<Set<string>>(new Set());
   // const [allPageIds] = useState<Set<string>>(new Set());
   // const [allPageIds] = useState<Set<string>>(new Set());
   const [selectedPageWithMeta, setSelectedPageWithMeta] = useState<IPageWithMeta<IPageSearchMeta> | undefined>();
   const [selectedPageWithMeta, setSelectedPageWithMeta] = useState<IPageWithMeta<IPageSearchMeta> | undefined>();
@@ -123,11 +125,6 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
     }
     }
   }, [onSelectedPagesByCheckboxesChanged, pages, selectedPageIdsByCheckboxes]);
   }, [onSelectedPagesByCheckboxesChanged, pages, selectedPageIdsByCheckboxes]);
 
 
-  useEffect(() => {
-    if (searchResultListHead != null && searchResultListHead.props != null) {
-      setHightlightKeywords(searchResultListHead.props.searchingKeyword);
-    }
-  }, [searchResultListHead]);
   if (!isSearchServiceConfigured) {
   if (!isSearchServiceConfigured) {
     return (
     return (
       <div className="grw-container-convertible">
       <div className="grw-container-convertible">
@@ -152,6 +149,10 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
     );
     );
   }
   }
 
 
+  const highlightKeywords = searchingKeyword != null
+    ? highlightKeywordsSplitter.exec(searchingKeyword) ?? undefined
+    : undefined;
+
   return (
   return (
     <div className="content-main">
     <div className="content-main">
       <div className="search-result-base d-flex" data-testid="search-result-base">
       <div className="search-result-base d-flex" data-testid="search-result-base">

+ 13 - 4
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -62,7 +62,6 @@ const bookmarkMenuItemClickHandler = async(_pageId: string, _newValue: boolean):
   await bookmarkOperation(_pageId);
   await bookmarkOperation(_pageId);
 };
 };
 
 
-
 /**
 /**
  * Return new page path after the droppedPagePath is moved under the newParentPagePath
  * Return new page path after the droppedPagePath is moved under the newParentPagePath
  * @param droppedPagePath
  * @param droppedPagePath
@@ -148,9 +147,13 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     }, 500);
     }, 500);
   }, []);
   }, []);
 
 
-  const [{ isDragging }, drag] = useDrag(() => ({
+  const [, drag] = useDrag({
     type: 'PAGE_TREE',
     type: 'PAGE_TREE',
     item: { page },
     item: { page },
+    canDrag: () => {
+      const isDraggable = !pagePathUtils.isUserPage(page.path || '/');
+      return isDraggable;
+    },
     end: (item, monitor) => {
     end: (item, monitor) => {
       // in order to set d-none to dropped Item
       // in order to set d-none to dropped Item
       const dropResult = monitor.getDropResult();
       const dropResult = monitor.getDropResult();
@@ -160,8 +163,9 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     },
     },
     collect: monitor => ({
     collect: monitor => ({
       isDragging: monitor.isDragging(),
       isDragging: monitor.isDragging(),
+      canDrag: monitor.canDrag(),
     }),
     }),
-  }));
+  });
 
 
   const pageItemDropHandler = async(item: ItemNode) => {
   const pageItemDropHandler = async(item: ItemNode) => {
     const { page: droppedPage } = item;
     const { page: droppedPage } = item;
@@ -373,6 +377,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     return null;
     return null;
   };
   };
 
 
+
   useEffect(() => {
   useEffect(() => {
     if (!props.isScrolled && page.isTarget) {
     if (!props.isScrolled && page.isTarget) {
       document.dispatchEvent(new CustomEvent('targetItemRendered'));
       document.dispatchEvent(new CustomEvent('targetItemRendered'));
@@ -406,7 +411,11 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   }, [data, isOpen, targetPathOrId]);
   }, [data, isOpen, targetPathOrId]);
 
 
   return (
   return (
-    <div id={`pagetree-item-${page._id}`} className={`grw-pagetree-item-container ${isOver ? 'grw-pagetree-is-over' : ''} ${shouldHide ? 'd-none' : ''}`}>
+    <div
+      id={`pagetree-item-${page._id}`}
+      className={`grw-pagetree-item-container ${isOver ? 'grw-pagetree-is-over' : ''}
+    ${shouldHide ? 'd-none' : ''}`}
+    >
       <li
       <li
         ref={(c) => { drag(c); drop(c) }}
         ref={(c) => { drag(c); drop(c) }}
         className={`list-group-item list-group-item-action border-0 py-0 d-flex align-items-center ${page.isTarget ? 'grw-pagetree-is-target' : ''}`}
         className={`list-group-item list-group-item-action border-0 py-0 d-flex align-items-center ${page.isTarget ? 'grw-pagetree-is-target' : ''}`}

+ 3 - 2
packages/app/src/server/routes/apiv3/pages.js

@@ -492,6 +492,7 @@ module.exports = (crowi) => {
     }
     }
 
 
     let page;
     let page;
+    let renamedPage;
 
 
     try {
     try {
       page = await Page.findByIdAndViewerToEdit(pageId, req.user, true);
       page = await Page.findByIdAndViewerToEdit(pageId, req.user, true);
@@ -508,14 +509,14 @@ module.exports = (crowi) => {
       if (!page.isEmpty && !page.isUpdatable(revisionId)) {
       if (!page.isEmpty && !page.isUpdatable(revisionId)) {
         return res.apiv3Err(new ErrorV3('Someone could update this page, so couldn\'t delete.', 'notfound_or_forbidden'), 409);
         return res.apiv3Err(new ErrorV3('Someone could update this page, so couldn\'t delete.', 'notfound_or_forbidden'), 409);
       }
       }
-      page = await crowi.pageService.renamePage(page, newPagePath, req.user, options);
+      renamedPage = await crowi.pageService.renamePage(page, newPagePath, req.user, options);
     }
     }
     catch (err) {
     catch (err) {
       logger.error(err);
       logger.error(err);
       return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
       return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
     }
     }
 
 
-    const result = { page: serializePageSecurely(page) };
+    const result = { page: serializePageSecurely(renamedPage ?? page) };
 
 
     try {
     try {
       // global notification
       // global notification

+ 1 - 1
packages/app/src/server/service/page-operation.ts

@@ -12,7 +12,7 @@ class PageOperationService {
     this.crowi = crowi;
     this.crowi = crowi;
 
 
     // TODO: Remove this code when resuming feature is implemented
     // TODO: Remove this code when resuming feature is implemented
-    PageOperation.deleteMany();
+    PageOperation.deleteMany({});
   }
   }
 
 
   /**
   /**

+ 91 - 12
packages/app/src/server/service/page.ts

@@ -47,7 +47,7 @@ class PageCursorsForDescendantsFactory {
 
 
   private shouldIncludeEmpty: boolean;
   private shouldIncludeEmpty: boolean;
 
 
-  private initialCursor: QueryCursor<any>; // TODO: wait for mongoose update
+  private initialCursor: QueryCursor<any> | never[]; // TODO: wait for mongoose update
 
 
   private Page: PageModel;
   private Page: PageModel;
 
 
@@ -69,11 +69,11 @@ class PageCursorsForDescendantsFactory {
    * Returns Iterable that yields only descendant pages unorderedly
    * Returns Iterable that yields only descendant pages unorderedly
    * @returns Promise<AsyncGenerator>
    * @returns Promise<AsyncGenerator>
    */
    */
-  async generateIterable(): Promise<AsyncGenerator> {
+  async generateIterable(): Promise<AsyncGenerator | never[]> {
     // initialize cursor
     // initialize cursor
     await this.init();
     await this.init();
 
 
-    return this.generateOnlyDescendants(this.initialCursor);
+    return this.isNeverArray(this.initialCursor) ? [] : this.generateOnlyDescendants(this.initialCursor);
   }
   }
 
 
   /**
   /**
@@ -90,13 +90,19 @@ class PageCursorsForDescendantsFactory {
   private async* generateOnlyDescendants(cursor: QueryCursor<any>) {
   private async* generateOnlyDescendants(cursor: QueryCursor<any>) {
     for await (const page of cursor) {
     for await (const page of cursor) {
       const nextCursor = await this.generateCursorToFindChildren(page);
       const nextCursor = await this.generateCursorToFindChildren(page);
-      yield* this.generateOnlyDescendants(nextCursor); // recursively yield
+      if (!this.isNeverArray(nextCursor)) {
+        yield* this.generateOnlyDescendants(nextCursor); // recursively yield
+      }
 
 
       yield page;
       yield page;
     }
     }
   }
   }
 
 
-  private async generateCursorToFindChildren(page: any): Promise<QueryCursor<any>> {
+  private async generateCursorToFindChildren(page: any): Promise<QueryCursor<any> | never[]> {
+    if (page == null) {
+      return [];
+    }
+
     const { PageQueryBuilder } = this.Page;
     const { PageQueryBuilder } = this.Page;
 
 
     const builder = new PageQueryBuilder(this.Page.find(), this.shouldIncludeEmpty);
     const builder = new PageQueryBuilder(this.Page.find(), this.shouldIncludeEmpty);
@@ -108,6 +114,10 @@ class PageCursorsForDescendantsFactory {
     return cursor;
     return cursor;
   }
   }
 
 
+  private isNeverArray(val: QueryCursor<any> | never[]): val is never[] {
+    return 'length' in val && val.length === 0;
+  }
+
 }
 }
 
 
 class PageService {
 class PageService {
@@ -444,11 +454,17 @@ class PageService {
     await Page.takeOffFromTree(page._id);
     await Page.takeOffFromTree(page._id);
 
 
     // 2. Find new parent
     // 2. Find new parent
-    const update: Partial<IPage> = {};
-    // find or create parent
-    const newParent = await Page.getParentAndFillAncestors(newPagePath);
+    let newParent;
+    // If renaming to under target, run getParentAndforceCreateEmptyTree to fill new ancestors
+    if (this.isRenamingToUnderTarget(page.path, newPagePath)) {
+      newParent = await this.getParentAndforceCreateEmptyTree(page, newPagePath);
+    }
+    else {
+      newParent = await Page.getParentAndFillAncestors(newPagePath);
+    }
 
 
     // 3. Put back target page to tree (also update the other attrs)
     // 3. Put back target page to tree (also update the other attrs)
+    const update: Partial<IPage> = {};
     update.path = newPagePath;
     update.path = newPagePath;
     update.parent = newParent._id;
     update.parent = newParent._id;
     if (updateMetadata) {
     if (updateMetadata) {
@@ -495,10 +511,7 @@ class PageService {
     await this.updateDescendantCountOfAncestors(renamedPage._id, nToIncrease, false);
     await this.updateDescendantCountOfAncestors(renamedPage._id, nToIncrease, false);
 
 
     // Remove leaf empty pages if not moving to under the ex-target position
     // Remove leaf empty pages if not moving to under the ex-target position
-    const pathToTest = escapeStringRegexp(addTrailingSlash(page.path));
-    const pathToBeTested = newPagePath;
-    const isRenamingToUnderExTarget = (new RegExp(`^${pathToTest}`)).test(pathToBeTested);
-    if (!isRenamingToUnderExTarget) {
+    if (!this.isRenamingToUnderTarget(page.path, newPagePath)) {
       // remove empty pages at leaf position
       // remove empty pages at leaf position
       await Page.removeLeafEmptyPagesRecursively(page.parent);
       await Page.removeLeafEmptyPagesRecursively(page.parent);
     }
     }
@@ -506,6 +519,72 @@ class PageService {
     await PageOperation.findByIdAndDelete(pageOpId);
     await PageOperation.findByIdAndDelete(pageOpId);
   }
   }
 
 
+  private isRenamingToUnderTarget(fromPath: string, toPath: string): boolean {
+    const pathToTest = escapeStringRegexp(addTrailingSlash(fromPath));
+    const pathToBeTested = toPath;
+
+    return (new RegExp(`^${pathToTest}`, 'i')).test(pathToBeTested);
+  }
+
+  private async getParentAndforceCreateEmptyTree(originalPage, toPath: string) {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    const fromPath = originalPage.path;
+    const newParentPath = pathlib.dirname(toPath);
+
+    // local util
+    const collectAncestorPathsUntilFromPath = (path: string, paths: string[] = [path]): string[] => {
+      const nextPath = pathlib.dirname(path);
+      if (nextPath === fromPath) {
+        return [...paths, nextPath];
+      }
+
+      paths.push(nextPath);
+
+      return collectAncestorPathsUntilFromPath(nextPath, paths);
+    };
+
+    const pathsToInsert = collectAncestorPathsUntilFromPath(newParentPath);
+    const originalParent = await Page.findById(originalPage.parent);
+    if (originalParent == null) {
+      throw Error('Original parent not found');
+    }
+    const insertedPages = await Page.insertMany(pathsToInsert.map((path) => {
+      return {
+        path,
+        isEmpty: true,
+      };
+    }));
+
+    const pages = [...insertedPages, originalParent];
+
+    const ancestorsMap = new Map<string, PageDocument & {_id: any}>(pages.map(p => [p.path, p]));
+
+    // bulkWrite to update ancestors
+    const operations = insertedPages.map((page) => {
+      const parentPath = pathlib.dirname(page.path);
+      const op = {
+        updateOne: {
+          filter: {
+            _id: page._id,
+          },
+          update: {
+            $set: {
+              parent: ancestorsMap.get(parentPath)?._id,
+              descedantCount: originalParent.descendantCount,
+            },
+          },
+        },
+      };
+
+      return op;
+    });
+    await Page.bulkWrite(operations);
+
+    const newParent = ancestorsMap.get(newParentPath);
+    return newParent;
+  }
+
   // !!renaming always include descendant pages!!
   // !!renaming always include descendant pages!!
   private async renamePageV4(page, newPagePath, user, options) {
   private async renamePageV4(page, newPagePath, user, options) {
     const Page = this.crowi.model('Page');
     const Page = this.crowi.model('Page');

+ 3 - 10
packages/app/src/server/service/search-delegator/elasticsearch.ts

@@ -635,7 +635,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
 
 
     // for debug
     // for debug
     if (process.env.NODE_ENV === 'development') {
     if (process.env.NODE_ENV === 'development') {
-      logger.debug('query: ', { query });
+      logger.debug('query: ', JSON.stringify(query, null, 2));
 
 
       const { body: result } = await this.client.indices.validateQuery({
       const { body: result } = await this.client.indices.validateQuery({
         index: query.index,
         index: query.index,
@@ -708,14 +708,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
     // default sort order is score descending
     // default sort order is score descending
     const sort = ES_SORT_AXIS[sortAxis] || ES_SORT_AXIS[RELATION_SCORE];
     const sort = ES_SORT_AXIS[sortAxis] || ES_SORT_AXIS[RELATION_SCORE];
     const order = ES_SORT_ORDER[sortOrder] || ES_SORT_ORDER[DESC];
     const order = ES_SORT_ORDER[sortOrder] || ES_SORT_ORDER[DESC];
-    query.sort = { [sort]: { order } };
-  }
-
-  convertSortQuery(sortAxis) {
-    switch (sortAxis) {
-      case RELATION_SCORE:
-        return '_score';
-    }
+    query.body.sort = { [sort]: { order } };
   }
   }
 
 
   initializeBoolQuery(query) {
   initializeBoolQuery(query) {
@@ -978,8 +971,8 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
     this.appendResultSize(query, from, size);
     this.appendResultSize(query, from, size);
 
 
     this.appendSortOrder(query, sort, order);
     this.appendSortOrder(query, sort, order);
-
     await this.appendFunctionScore(query, queryString);
     await this.appendFunctionScore(query, queryString);
+
     this.appendHighlight(query);
     this.appendHighlight(query);
 
 
     return this.searchKeyword(query);
     return this.searchKeyword(query);

+ 1 - 1
packages/app/src/styles/theme/_apply-colors.scss

@@ -647,7 +647,7 @@ body.pathname-sidebar {
   }
   }
   .page-list {
   .page-list {
     .highlighted-keyword {
     .highlighted-keyword {
-      background-color: $bgcolor-keyword-highlighted;
+      background: linear-gradient(transparent 60%, $bgcolor-keyword-highlighted 60%);
     }
     }
   }
   }
 }
 }

+ 13 - 0
packages/core/src/utils/page-path-utils.ts

@@ -47,6 +47,19 @@ export const isMovablePage = (path: string): boolean => {
   return !isTopPage(path) && !isUsersProtectedPages(path);
   return !isTopPage(path) && !isUsersProtectedPages(path);
 };
 };
 
 
+/**
+ * Whether path belongs to the user page
+ * @param path
+ */
+export const isUserPage = (path: string): boolean => {
+  // https://regex101.com/r/BSDdRr/1
+  if (path.match(/^\/user(\/.*)?$/)) {
+    return true;
+  }
+
+  return false;
+};
+
 /**
 /**
  * Whether path belongs to the trash page
  * Whether path belongs to the trash page
  * @param path
  * @param path