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

Merge branch 'dev/5.0.x' into imprv/center-plus-button

stevenfukase 4 лет назад
Родитель
Сommit
d4975205b0

+ 3 - 0
packages/app/resource/locales/en_US/translation.json

@@ -930,5 +930,8 @@
     "success_to_send_email": "Success to send email",
     "incorrect_token_or_expired_url": "The token is incorrect or the URL has expired. Please resend a password reset request via the link below.",
     "password_and_confirm_password_does_not_match": "Password and confirm password does not match"
+  },
+  "pagetree": {
+    "private_legacy_pages": "Private Legacy Pages"
   }
 }

+ 3 - 0
packages/app/resource/locales/ja_JP/translation.json

@@ -923,5 +923,8 @@
     "success_to_send_email": "メールを送信しました",
     "incorrect_token_or_expired_url":"トークンが正しくないか、URLの有効期限が切れています。 以下のリンクからパスワードリセットリクエストを再送信してください。",
     "password_and_confirm_password_does_not_match": "パスワードと確認パスワードが一致しません"
+  },
+  "pagetree": {
+    "private_legacy_pages": "待避所"
   }
 }

+ 3 - 0
packages/app/resource/locales/zh_CN/translation.json

@@ -933,5 +933,8 @@
     "success_to_send_email": "我发了一封电子邮件",
     "incorrect_token_or_expired_url":"令牌不正确或 URL 已过期。 请通过以下链接重新发送密码重置请求",
     "password_and_confirm_password_does_not_match": "密码和确认密码不匹配"
+  },
+  "pagetree": {
+    "private_legacy_pages": "私人遗留页面"
   }
 }

+ 5 - 2
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -8,12 +8,15 @@ import { IPageHasId } from '~/interfaces/page';
 type PageItemControlProps = {
   page: Partial<IPageHasId>
   isEnableActions: boolean
+  isDeletable: boolean
   onClickDeleteButton?: (pageId: string) => void
 }
 
 const PageItemControl: FC<PageItemControlProps> = (props: PageItemControlProps) => {
 
-  const { page, isEnableActions, onClickDeleteButton } = props;
+  const {
+    page, isEnableActions, onClickDeleteButton, isDeletable,
+  } = props;
   const { t } = useTranslation('');
 
   const deleteButtonHandler = () => {
@@ -74,7 +77,7 @@ const PageItemControl: FC<PageItemControlProps> = (props: PageItemControlProps)
             {t('Move/Rename')}
           </button>
         )}
-        {isEnableActions && (
+        {isDeletable && isEnableActions && (
           <>
             <div className="dropdown-divider"></div>
             <button className="dropdown-item text-danger pt-2" type="button" onClick={deleteButtonHandler}>

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

@@ -34,7 +34,7 @@ const DeleteSelectedPageGroup:FC<Props> = (props:Props) => {
         id="check-all-pages"
         type="checkbox"
         name="check-all-pages"
-        className="custom-control custom-checkbox ml-1 align-self-center"
+        className="custom-control custom-checkbox align-self-center"
         disabled={props.isSelectAllCheckboxDisabled}
         onClick={onClickCheckbox}
         checked={selectAllCheckboxType !== CheckboxType.NONE_CHECKED}

+ 28 - 24
packages/app/src/components/SearchPage/SearchControl.tsx

@@ -98,8 +98,8 @@ const SearchControl: FC <Props> = (props: Props) => {
         </div>
       </div>
       {/* TODO: replace the following elements deleteAll button , relevance button and include specificPath button component */}
-      <div className="search-control d-flex align-items-center py-2 border-bottom border-gray">
-        <div className="d-flex mr-auto ml-4">
+      <div className="search-control d-flex align-items-center py-md-2 py-3 px-md-3 px-3 border-bottom border-gray">
+        <div className="d-flex pl-md-2">
           {/* Todo: design will be fixed in #80324. Function will be implemented in #77525 */}
           <DeleteSelectedPageGroup
             isSelectAllCheckboxDisabled={searchResultCount === 0}
@@ -109,7 +109,7 @@ const SearchControl: FC <Props> = (props: Props) => {
           />
         </div>
         {/** filter option */}
-        <div className="d-lg-none mr-4">
+        <div className="d-lg-none ml-auto">
           <button
             type="button"
             className="btn"
@@ -118,28 +118,32 @@ const SearchControl: FC <Props> = (props: Props) => {
             <i className="icon-equalizer"></i>
           </button>
         </div>
-        <div className="d-none d-lg-flex align-items-center mr-4">
-          <div className="border border-gray mr-3">
-            <label className="px-3 py-2 mb-0 d-flex align-items-center text-secondary with-no-font-weight" htmlFor="flexCheckDefault">
-              <input
-                className="mr-2"
-                type="checkbox"
-                id="flexCheckDefault"
-                onClick={switchExcludeUserPagesHandler}
-              />
-              {t('Include Subordinated Target Page', { target: '/user' })}
-            </label>
+        <div className="d-none d-lg-flex align-items-center ml-auto search-control-include-options">
+          <div className="card mr-3 mb-0">
+            <div className="card-body">
+              <label className="search-include-label mb-0 d-flex align-items-center text-secondary with-no-font-weight" htmlFor="flexCheckDefault">
+                <input
+                  className="mr-2"
+                  type="checkbox"
+                  id="flexCheckDefault"
+                  onClick={switchExcludeUserPagesHandler}
+                />
+                {t('Include Subordinated Target Page', { target: '/user' })}
+              </label>
+            </div>
           </div>
-          <div className="border border-gray">
-            <label className="px-3 py-2 mb-0 d-flex align-items-center text-secondary with-no-font-weight" htmlFor="flexCheckChecked">
-              <input
-                className="mr-2"
-                type="checkbox"
-                id="flexCheckChecked"
-                onClick={switchExcludeTrashPagesHandler}
-              />
-              {t('Include Subordinated Target Page', { target: '/trash' })}
-            </label>
+          <div className="card mb-0">
+            <div className="card-body">
+              <label className="search-include-label mb-0 d-flex align-items-center text-secondary with-no-font-weight" htmlFor="flexCheckChecked">
+                <input
+                  className="mr-2"
+                  type="checkbox"
+                  id="flexCheckChecked"
+                  onClick={switchExcludeTrashPagesHandler}
+                />
+                {t('Include Subordinated Target Page', { target: '/trash' })}
+              </label>
+            </div>
           </div>
         </div>
       </div>

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

@@ -43,7 +43,7 @@ const SearchOptionModal: FC<Props> = (props: Props) => {
         Search Option
       </ModalHeader>
       <ModalBody>
-        <div className="d-flex p-3">
+        <div className="d-flex p-2">
           <div className="border border-gray mr-3">
             <label className="px-3 py-2 mb-0 d-flex align-items-center">
               <input

+ 8 - 1
packages/app/src/components/SearchPage/SearchResultListItem.tsx

@@ -3,10 +3,12 @@ import React, { FC, memo } from 'react';
 import Clamp from 'react-multiline-clamp';
 
 import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui';
+import { pagePathUtils } from '@growi/core';
 
 import { IPageSearchResultData } from '../../interfaces/search';
 import PageItemControl from '../Common/Dropdown/PageItemControl';
 
+const { isTopPage } = pagePathUtils;
 
 type Props = {
   page: IPageSearchResultData,
@@ -84,7 +86,12 @@ const SearchResultListItem: FC<Props> = memo((props:Props) => {
               </div>
               {/* doropdown icon includes page control buttons */}
               <div className="ml-auto">
-                <PageItemControl page={pageData} onClickDeleteButton={props.onClickDeleteButton} isEnableActions={isEnableActions} />
+                <PageItemControl
+                  page={pageData}
+                  onClickDeleteButton={props.onClickDeleteButton}
+                  isEnableActions={isEnableActions}
+                  isDeletable={!isTopPage(pageData.path)}
+                />
               </div>
             </div>
             <div className="my-2 search-result-list-snippet">

+ 5 - 7
packages/app/src/components/Sidebar/PageTree.tsx

@@ -64,13 +64,11 @@ const PageTree: FC = memo(() => {
         />
       </div>
 
-      <div className="grw-sidebar-content-footer">
-        {
-          !isGuestUser && migrationStatus?.migratablePagesCount != null && migrationStatus.migratablePagesCount !== 0 && (
-            <PrivateLegacyPages />
-          )
-        }
-      </div>
+      {!isGuestUser && migrationStatus?.migratablePagesCount != null && migrationStatus.migratablePagesCount !== 0 && (
+        <div className="grw-pagetree-footer border-top p-3 w-100">
+          <PrivateLegacyPages />
+        </div>
+      )}
     </>
   );
 });

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

@@ -3,6 +3,7 @@ import React, {
 } from 'react';
 import nodePath from 'path';
 import { useTranslation } from 'react-i18next';
+import { pagePathUtils } from '@growi/core';
 
 import { ItemNode } from './ItemNode';
 import { IPageHasId } from '~/interfaces/page';
@@ -13,6 +14,8 @@ import { IPageForPageDeleteModal } from '~/components/PageDeleteModal';
 
 import TriangleIcon from '~/components/Icons/TriangleIcon';
 
+const { isTopPage } = pagePathUtils;
+
 
 interface ItemProps {
   isEnableActions: boolean
@@ -39,6 +42,7 @@ const markTarget = (children: ItemNode[], targetId?: string): void => {
 type ItemControlProps = {
   page: Partial<IPageHasId>
   isEnableActions: boolean
+  isDeletable: boolean
   onClickDeleteButtonHandler?(): void
   onClickPlusButtonHandler?(): void
 }
@@ -66,7 +70,7 @@ const ItemControl: FC<ItemControlProps> = memo((props: ItemControlProps) => {
 
   return (
     <>
-      <PageItemControl page={props.page} onClickDeleteButton={onClickDeleteButton} isEnableActions={props.isEnableActions} />
+      <PageItemControl page={props.page} onClickDeleteButton={onClickDeleteButton} isEnableActions={props.isEnableActions} isDeletable={props.isDeletable} />
       <button
         type="button"
         className="btn-link nav-link border-0 rounded grw-btn-page-management p-0"
@@ -171,20 +175,14 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       markTarget(newChildren, targetId);
       setCurrentChildren(newChildren);
     }
-  }, [data]);
-
-  // TODO: improve style
-  const opacityStyle = { opacity: 1.0 };
-  if (page.isTarget) opacityStyle.opacity = 0.7;
-
-  const buttonClass = isOpen ? 'grw-pagetree-open' : '';
+  }, [data, isOpen]);
 
   return (
     <>
-      <div style={opacityStyle} className="grw-pagetree-item d-flex align-items-center pr-1">
+      <div className={`grw-pagetree-item d-flex align-items-center pr-1 ${page.isTarget ? 'grw-pagetree-is-target' : ''}`}>
         <button
           type="button"
-          className={`grw-pagetree-button btn ${buttonClass}`}
+          className={`grw-pagetree-button btn ${isOpen ? 'grw-pagetree-open' : ''}`}
           onClick={onClickLoadChildren}
         >
           <div className="grw-triangle-icon">
@@ -203,11 +201,12 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
             onClickDeleteButtonHandler={onClickDeleteButtonHandler}
             onClickPlusButtonHandler={() => { setNewPageInputShown(true) }}
             isEnableActions={isEnableActions}
+            isDeletable={!page.isEmpty && !isTopPage(page.path as string)}
           />
         </div>
       </div>
 
-      {!isEnableActions && (
+      {isEnableActions && (
         <ClosableTextInput
           isShown={isNewPageInputShown}
           placeholder={t('Input title')}
@@ -218,12 +217,12 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       )}
       {
         isOpen && hasChildren() && currentChildren.map(node => (
-          <div className="ml-3 mt-2">
+          <div key={node.page._id} className="ml-3 mt-2">
             <Item
-              key={node.page._id}
               isEnableActions={isEnableActions}
               itemNode={node}
               isOpen={false}
+              targetId={targetId}
               onClickDeleteByPage={onClickDeleteByPage}
             />
           </div>

+ 5 - 3
packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx

@@ -1,4 +1,4 @@
-import React, { FC } from 'react';
+import React, { FC, useState } from 'react';
 
 import { IPageHasId } from '../../../interfaces/page';
 import { ItemNode } from './ItemNode';
@@ -37,7 +37,6 @@ const generateInitialNodeAfterResponse = (ancestorsChildren: Record<string, Part
 
     const childPages = ancestorsChildren[path];
     currentNode.children = ItemNode.generateNodesFromPages(childPages);
-
     const nextNode = currentNode.children.filter((node) => {
       return paths.includes(node.page.path as string);
     })[0];
@@ -94,6 +93,8 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
   const { data: ancestorsChildrenData, error: error1 } = useSWRxPageAncestorsChildren(targetPath);
   const { data: rootPageData, error: error2 } = useSWRxRootPage();
 
+  const [isRenderedCompletely, setRenderedCompletely] = useState(false);
+
   const DeleteModal = (
     <PageDeleteModal
       isOpen={isDeleteModalOpen}
@@ -113,8 +114,9 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
   /*
    * Render completely
    */
-  if (ancestorsChildrenData != null && rootPageData != null) {
+  if (!isRenderedCompletely && ancestorsChildrenData != null && rootPageData != null) {
     const initialNode = generateInitialNodeAfterResponse(ancestorsChildrenData.ancestorsChildren, new ItemNode(rootPageData.rootPage));
+    setRenderedCompletely(true); // render once
     return renderByInitialNode(initialNode, DeleteModal, isEnableActions, targetId, onClickDeleteByPage);
   }
 

+ 3 - 5
packages/app/src/components/Sidebar/PageTree/PrivateLegacyPages.tsx

@@ -5,11 +5,9 @@ const PrivateLegacyPages: FC = memo(() => {
   const { t } = useTranslation();
 
   return (
-    <div className="grw-prvt-legacy-pages p-3">
-      <a href="/private-legacy-pages?q=[nq:PrivateLegacyPages]" className="h5">
-        <i className="icon-drawer mr-2"></i> PrivateLegacyPages
-      </a>
-    </div>
+    <a href="/private-legacy-pages?q=[nq:PrivateLegacyPages]" className="h5 grw-private-legacy-pages-anchor text-decoration-none">
+      <i className="icon-drawer mr-2"></i> {t('pagetree.private_legacy_pages')}
+    </a>
   );
 });
 

+ 3 - 3
packages/app/src/server/models/page.ts

@@ -39,7 +39,7 @@ type TargetAndAncestorsResult = {
 }
 export interface PageModel extends Model<PageDocument> {
   [x: string]: any; // for obsolete methods
-  createEmptyPagesByPaths(paths: string[]): Promise<void>
+  createEmptyPagesByPaths(paths: string[], publicOnly?: boolean): Promise<void>
   getParentIdAndFillAncestors(path: string): Promise<string | null>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: boolean): Promise<PageDocument[]>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
@@ -141,9 +141,9 @@ const generateChildrenRegExp = (path: string): RegExp => {
 /*
  * Create empty pages if the page in paths didn't exist
  */
-schema.statics.createEmptyPagesByPaths = async function(paths: string[]): Promise<void> {
+schema.statics.createEmptyPagesByPaths = async function(paths: string[], publicOnly = false): Promise<void> {
   // find existing parents
-  const builder = new PageQueryBuilder(this.find({}, { _id: 0, path: 1 }));
+  const builder = new PageQueryBuilder(this.find(publicOnly ? { grant: GRANT_PUBLIC } : {}, { _id: 0, path: 1 }));
   const existingPages = await builder
     .addConditionToListByPathsArray(paths)
     .query

+ 17 - 15
packages/app/src/server/service/page.js

@@ -866,35 +866,37 @@ class PageService {
   }
 
   async v5InitialMigration(grant) {
-    const socket = this.crowi.socketIoService.getAdminSocket();
-    try {
-      await this._v5RecursiveMigration(grant);
-    }
-    catch (err) {
-      logger.error('V5 initial miration failed.', err);
-      socket.emit('v5InitialMirationFailed', { error: err.message });
-
-      throw err;
-    }
-
+    // const socket = this.crowi.socketIoService.getAdminSocket();
     const Page = this.crowi.model('Page');
     const indexStatus = await Page.aggregate([{ $indexStats: {} }]);
     const pathIndexStatus = indexStatus.filter(status => status.name === 'path_1')?.[0];
     const isPathIndexExists = pathIndexStatus != null;
     const isUnique = isPathIndexExists && pathIndexStatus.spec?.unique === true;
 
+    // drop unique index first
     if (isUnique || !isPathIndexExists) {
       try {
         await this._v5NormalizeIndex(isPathIndexExists);
       }
       catch (err) {
         logger.error('V5 index normalization failed.', err);
-        socket.emit('v5IndexNormalizationFailed', { error: err.message });
+        // socket.emit('v5IndexNormalizationFailed', { error: err.message });
 
         throw err;
       }
     }
 
+    // then migrate
+    try {
+      await this._v5RecursiveMigration(grant, null, true);
+    }
+    catch (err) {
+      logger.error('V5 initial miration failed.', err);
+      // socket.emit('v5InitialMirationFailed', { error: err.message });
+
+      throw err;
+    }
+
     await this._setIsV5CompatibleTrue();
   }
 
@@ -933,7 +935,7 @@ class PageService {
   }
 
   // TODO: use websocket to show progress
-  async _v5RecursiveMigration(grant, regexps) {
+  async _v5RecursiveMigration(grant, regexps, publicOnly = false) {
     const BATCH_SIZE = 100;
     const PAGES_LIMIT = 1000;
     const Page = this.crowi.model('Page');
@@ -996,7 +998,7 @@ class PageService {
         const parentPaths = Array.from(parentPathsSet);
 
         // fill parents with empty pages
-        await Page.createEmptyPagesByPaths(parentPaths);
+        await Page.createEmptyPagesByPaths(parentPaths, publicOnly);
 
         // find parents again
         const builder = new PageQueryBuilder(Page.find({}, { _id: 1, path: 1 }));
@@ -1071,7 +1073,7 @@ class PageService {
     await streamToPromise(migratePagesStream);
 
     if (await Page.exists(filter) && shouldContinue) {
-      return this._v5RecursiveMigration(grant, regexps);
+      return this._v5RecursiveMigration(grant, regexps, publicOnly);
     }
 
   }

+ 5 - 1
packages/app/src/styles/_page-tree.scss

@@ -1,4 +1,9 @@
+$grw-sidebar-content-header-height: 58px;
+$grw-sidebar-content-footer-height: 50px;
+
 .grw-pagetree {
+  min-height: calc(100vh - ($grw-navbar-height + $grw-navbar-border-width + $grw-sidebar-content-header-height + $grw-sidebar-content-footer-height));
+
   .grw-pagetree-item {
     height: 2.5rem;
 
@@ -31,7 +36,6 @@
     .grw-pagetree-title-anchor {
       width: 100%;
       overflow: hidden;
-      color: inherit;
       text-decoration: none;
 
       .grw-pagetree-title {

+ 8 - 0
packages/app/src/styles/_search.scss

@@ -166,6 +166,14 @@
 // TODO : keep the selected list in the same positino as other lists
 // TASK : https://redmine.weseek.co.jp/issues/82470
 .search-result {
+  .search-control {
+    padding: 5px 0;
+  }
+  .search-control-include-options {
+    .card-body {
+      padding: 5px 10px;
+    }
+  }
   .search-result-list {
     position: sticky;
     top: 0px;

+ 0 - 7
packages/app/src/styles/_sidebar.scss

@@ -235,13 +235,6 @@
       font-size: 18px;
     }
   }
-
-  .grw-sidebar-content-footer {
-    position: absolute;
-    bottom: 0;
-    width: 100%;
-    border-top: solid 1px $border-color;
-  }
 }
 
 // Dock Mode

+ 4 - 0
packages/app/src/styles/theme/_apply-colors-dark.scss

@@ -255,6 +255,10 @@ ul.pagination {
   // Pagetree
   .grw-pagetree {
     .grw-pagetree-item {
+      &.grw-pagetree-is-target {
+        background: $bgcolor-list-hover;
+      }
+
       .grw-triangle-icon {
         &:not(:hover) {
           svg {

+ 4 - 0
packages/app/src/styles/theme/_apply-colors-light.scss

@@ -172,6 +172,10 @@ $border-color: $border-color-global;
   // Pagetree
   .grw-pagetree {
     .grw-pagetree-item {
+      &.grw-pagetree-is-target {
+        background: $bgcolor-list-hover;
+      }
+
       .grw-triangle-icon {
         &:not(:hover) {
           svg {

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

@@ -310,6 +310,19 @@ ul.pagination {
     }
   }
 
+  .grw-pagetree {
+    .grw-pagetree-item {
+      .grw-pagetree-title-anchor {
+        color: inherit;
+      }
+    }
+  }
+  .grw-pagetree-footer {
+    .h5.grw-private-legacy-pages-anchor {
+      color: inherit;
+    }
+  }
+
   .grw-recent-changes {
     .list-group {
       .list-group-item {

+ 78 - 0
packages/app/src/test/integration/service/page.test.js

@@ -871,5 +871,83 @@ describe('PageService', () => {
 
   });
 
+  describe('v5InitialMigration()', () => {
+    test('should migrate all public pages & replace private parents with empty pages', async() => {
+      jest.restoreAllMocks();
+
+      // initialize pages for test
+      const pages = await Page.insertMany([
+        {
+          path: '/publicA',
+          grant: Page.GRANT_PUBLIC,
+          creator: testUser1,
+          lastUpdateUser: testUser1,
+        },
+        {
+          path: '/publicA/privateB',
+          grant: Page.GRANT_OWNER,
+          creator: testUser1,
+          lastUpdateUser: testUser1,
+        },
+        {
+          path: '/publicA/privateB/publicC',
+          grant: Page.GRANT_PUBLIC,
+          creator: testUser1,
+          lastUpdateUser: testUser1,
+        },
+        {
+          path: '/parenthesis/(a)[b]{c}d',
+          grant: Page.GRANT_PUBLIC,
+          creator: testUser1,
+          lastUpdateUser: testUser1,
+        },
+        {
+          path: '/parenthesis/(a)[b]{c}d/public',
+          grant: Page.GRANT_PUBLIC,
+          creator: testUser1,
+          lastUpdateUser: testUser1,
+        },
+      ]);
+
+      const parent = await Page.find({ path: '/' });
+      await Page.insertMany([
+        {
+          path: '/migratedD',
+          grant: Page.GRANT_PUBLIC,
+          creator: testUser1,
+          lastUpdateUser: testUser1,
+          parent: parent._id,
+        },
+      ]);
+
+      // migrate
+      await crowi.pageService.v5InitialMigration(Page.GRANT_PUBLIC);
+
+      const nMigratedPages = await Page.count({
+        path: {
+          $in: ['/publicA', '/publicA/privateB/publicC', '/parenthesis/(a)[b]{c}d', '/parenthesis/(a)[b]{c}d/public', '/migratedD'],
+        },
+        isEmpty: false,
+        parent: { $ne: null },
+      });
+      const nMigratedEmptyPages = await Page.count({
+        path: {
+          $in: ['/publicA/privateB', '/parenthesis'],
+        },
+        isEmpty: true,
+        parent: { $ne: null },
+      });
+      const nNonMigratedPages = await Page.count({
+        path: {
+          $in: ['/publicA/privateB'],
+        },
+        parent: null,
+      });
+
+      expect(nMigratedPages).toBe(5);
+      expect(nMigratedEmptyPages).toBe(2);
+      expect(nNonMigratedPages).toBe(1);
+    });
+  });
 
 });