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

Merge pull request #3230 from weseek/feat/duplicate-with-subordinate-page

feat/duplicate with subordinate page
Yuki Takei 5 лет назад
Родитель
Сommit
c870de44bd
35 измененных файлов с 1280 добавлено и 167 удалено
  1. 5 0
      package.json
  2. 14 1
      resource/locales/en_US/translation.json
  3. 14 1
      resource/locales/ja_JP/translation.json
  4. 18 5
      resource/locales/zh_CN/translation.json
  5. 60 0
      src/client/js/components/ComparePathsTable.jsx
  6. 59 0
      src/client/js/components/DuplicatedPathsTable.jsx
  7. 7 8
      src/client/js/components/EmptyTrashModal.jsx
  8. 0 2
      src/client/js/components/Page/PageManagement.jsx
  9. 6 8
      src/client/js/components/PageDeleteModal.jsx
  10. 119 22
      src/client/js/components/PageDuplicateModal.jsx
  11. 23 0
      src/client/js/components/PageManagement/ApiErrorMessageList.jsx
  12. 96 15
      src/client/js/components/PageRenameModal.jsx
  13. 5 8
      src/client/js/components/PutbackPageModal.jsx
  14. 14 16
      src/client/js/services/PageContainer.js
  15. 9 0
      src/client/styles/scss/_page-duplicate-modal.scss
  16. 5 0
      src/client/styles/scss/molecules/compare-paths-table.scss
  17. 5 0
      src/client/styles/scss/molecules/duplicated-paths-table.scss
  18. 3 0
      src/client/styles/scss/style-app.scss
  19. 19 0
      src/lib/util/path-utils.js
  20. 15 0
      src/lib/util/to-array-from-csv.js
  21. 16 0
      src/server/crowi/index.js
  22. 25 2
      src/server/models/page.js
  23. 2 2
      src/server/routes/apiv3/notification-setting.js
  24. 62 0
      src/server/routes/apiv3/page.js
  25. 500 18
      src/server/routes/apiv3/pages.js
  26. 2 2
      src/server/routes/apiv3/response.js
  27. 1 1
      src/server/routes/apiv3/share-links.js
  28. 1 1
      src/server/routes/apiv3/user-group.js
  29. 1 1
      src/server/routes/apiv3/users.js
  30. 0 2
      src/server/routes/index.js
  31. 1 51
      src/server/routes/page.js
  32. 56 0
      src/server/service/page.js
  33. 50 0
      src/server/service/user-notification/index.js
  34. 30 0
      src/test/libs/to-array-from-csv.test.js
  35. 37 1
      src/test/util/path-utils.test.js

+ 5 - 0
package.json

@@ -267,6 +267,11 @@
     "@alias/logger": "src/lib/service/logger",
     "debug": "src/lib/service/logger/alias-for-debug"
   },
+  "jest": {
+    "moduleNameMapper": {
+      "@commons/(.*)": "<rootDir>/src/lib/$1"
+    }
+  },
   "engines": {
     "node": "^12 || ^14",
     "npm": ">=6.11.3 <7",

+ 14 - 1
resource/locales/en_US/translation.json

@@ -143,6 +143,9 @@
   "No bookmarks yet": "No bookmarks yet",
   "Recent Created": "Recent Created",
   "Recent Changes": "Recent Changes",
+  "original_path":"Original path",
+  "new_path":"New path",
+  "duplicated_path":"duplicated_path",
   "personal_dropdown": {
     "home": "Home",
     "settings": "Settings",
@@ -318,6 +321,9 @@
     "label": {
       "Move/Rename page": "Move/Rename page",
       "New page name": "New page name",
+      "Fail to get subordinated pages": "Fail to get subordinated pages",
+      "Fail to get exist path": "Fail to get exist path",
+      "Rename without exist path": "Rename without exist path",
       "Current page name": "Current page name",
       "Recursively": "Recursively",
       "Do not update metadata": "Do not update metadata",
@@ -348,7 +354,14 @@
     "label": {
       "Duplicate page": "Duplicate page",
       "New page name": "New page name",
-      "Current page name": "Current page name"
+      "Fail to get subordinated pages": "Fail to get subordinated pages",
+      "Current page name": "Current page name",
+      "Recursively": "Recursively",
+      "Duplicate without exist path": "Duplicate without exist path",
+      "Same page already exists": "Same page already exists"
+    },
+    "help": {
+      "recursive": "Duplicate children of under this path recursively"
     }
   },
   "modal_putback": {

+ 14 - 1
resource/locales/ja_JP/translation.json

@@ -146,6 +146,9 @@
   "No bookmarks yet": "No bookmarks yet",
   "Recent Created": "最新の作成",
   "Recent Changes": "最新の変更",
+  "original_path":"元のパス",
+  "new_path":"新しいパス",
+  "duplicated_path":"重複したパス",
   "personal_dropdown": {
     "home": "ホーム",
     "settings": "設定",
@@ -320,6 +323,9 @@
     "label": {
       "Move/Rename page": "ページを移動/名前変更する",
       "New page name": "移動先のページ名",
+      "Fail to get subordinated pages": "配下ページの取得に失敗しました",
+      "Fail to get exist path": "存在するパスの取得に失敗しました",
+      "Rename without exist path": "存在するパス以外を名前変更する",
       "Current page name": "現在のページ名",
       "Recursively": "再帰的に移動/名前変更",
       "Do not update metadata": "メタデータを更新しない",
@@ -350,7 +356,14 @@
     "label": {
       "Duplicate page": "ページを複製する",
       "New page name": "複製後のページ名",
-      "Current page name": "現在のページ名"
+      "Fail to get subordinated pages": "配下ページの取得に失敗しました",
+      "Current page name": "現在のページ名",
+      "Recursively": "再帰的に複製",
+      "Duplicate without exist path": "存在するパス以外を複製する",
+      "Same page already exists": "同じページがすでに存在します"
+    },
+    "help": {
+      "recursive": "配下のページも複製します"
     }
   },
   "modal_putback": {

+ 18 - 5
resource/locales/zh_CN/translation.json

@@ -151,7 +151,10 @@
   "Disassociate": "解除关联",
   "No bookmarks yet": "暂无书签",
 	"Recent Created": "最新创建",
-	"Recent Changes": "最新修改",
+  "Recent Changes": "最新修改",
+  "original_path":"Original path",
+  "new_path":"New path",
+  "duplicated_path":"duplicated_path",
 	"form_validation": {
 		"error_message": "有些值不正确",
 		"required": "%s 是必需的",
@@ -298,7 +301,10 @@
 	"modal_rename": {
 		"label": {
 			"Move/Rename page": "页面 移动/重命名",
-			"New page name": "新建页面名称",
+      "New page name": "新建页面名称",
+      "Fail to get subordinated pages": "Fail to get subordinated pages",
+      "Fail to get exist path": "Fail to get exist path",
+      "Rename without exist path": "Rename without exist path",
 			"Current page name": "当前页面名称",
 			"Recursively": "递归地",
 			"Do not update metadata": "不更新元数据",
@@ -328,9 +334,16 @@
 	"modal_duplicate": {
 		"label": {
 			"Duplicate page": "Duplicate page",
-			"New page name": "New page name",
-			"Current page name": "Current page name"
-		}
+      "New page name": "New page name",
+      "Fail to get subordinated pages": "Fail to get subordinated pages",
+			"Current page name": "Current page name",
+      "Recursively": "Recursively",
+      "Duplicate without exist path": "Duplicate without exist path",
+      "Same page already exists": "Same page already exists"
+    },
+    "help": {
+      "recursive": "Duplicate children of under this path recursively"
+    }
 	},
 	"modal_putback": {
 		"label": {

+ 60 - 0
src/client/js/components/ComparePathsTable.jsx

@@ -0,0 +1,60 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+import { withUnstatedContainers } from './UnstatedUtils';
+
+import PageContainer from '../services/PageContainer';
+import { convertToNewAffiliationPath } from '../../../lib/util/path-utils';
+
+function ComparePathsTable(props) {
+  const {
+    subordinatedPages, pageContainer, newPagePath, t,
+  } = props;
+  const { path } = pageContainer.state;
+
+  return (
+    <table className="table table-bordered grw-compare-paths-table">
+      <thead>
+        <tr className="d-flex">
+          <th className="w-50">{t('original_path')}</th>
+          <th className="w-50">{t('new_path')}</th>
+        </tr>
+      </thead>
+      <tbody className="overflow-auto d-block">
+        {subordinatedPages.map((subordinatedPage) => {
+          const convertedPath = convertToNewAffiliationPath(path, newPagePath, subordinatedPage.path);
+          return (
+            <tr key={subordinatedPage._id} className="d-flex">
+              <td className="text-break w-50">
+                <a href={subordinatedPage.path}>
+                  {subordinatedPage.path}
+                </a>
+              </td>
+              <td className="text-break w-50">
+                {convertedPath}
+              </td>
+            </tr>
+          );
+        })}
+      </tbody>
+    </table>
+  );
+}
+
+
+/**
+ * Wrapper component for using unstated
+ */
+const PageDuplicateModallWrapper = withUnstatedContainers(ComparePathsTable, [PageContainer]);
+
+ComparePathsTable.propTypes = {
+  t: PropTypes.func.isRequired, //  i18next
+
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  subordinatedPages: PropTypes.array.isRequired,
+  newPagePath: PropTypes.string.isRequired,
+};
+
+
+export default withTranslation()(PageDuplicateModallWrapper);

+ 59 - 0
src/client/js/components/DuplicatedPathsTable.jsx

@@ -0,0 +1,59 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+import { withUnstatedContainers } from './UnstatedUtils';
+
+import PageContainer from '../services/PageContainer';
+import { convertToNewAffiliationPath } from '../../../lib/util/path-utils';
+
+function DuplicatedPathsTable(props) {
+  const {
+    pageContainer, oldPagePath, existingPaths, t,
+  } = props;
+  const { path } = pageContainer.state;
+
+  return (
+    <table className="table table-bordered grw-duplicated-paths-table">
+      <thead>
+        <tr className="d-flex">
+          <th className="w-50">{t('original_path')}</th>
+          <th className="w-50 text-danger">{t('duplicated_path')}</th>
+        </tr>
+      </thead>
+      <tbody className="overflow-auto d-block">
+        {existingPaths.map((existPath) => {
+          const convertedPath = convertToNewAffiliationPath(oldPagePath, path, existPath);
+          return (
+            <tr key={existPath} className="d-flex">
+              <td className="text-break w-50">
+                <a href={convertedPath}>
+                  {convertedPath}
+                </a>
+              </td>
+              <td className="text-break text-danger w-50">
+                {existPath}
+              </td>
+            </tr>
+          );
+        })}
+      </tbody>
+    </table>
+  );
+}
+
+
+/**
+ * Wrapper component for using unstated
+ */
+const PageDuplicateModallWrapper = withUnstatedContainers(DuplicatedPathsTable, [PageContainer]);
+
+DuplicatedPathsTable.propTypes = {
+  t: PropTypes.func.isRequired, //  i18next
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  existingPaths: PropTypes.array.isRequired,
+  oldPagePath: PropTypes.string.isRequired,
+};
+
+
+export default withTranslation()(PageDuplicateModallWrapper);

+ 7 - 8
src/client/js/components/EmptyTrashModal.jsx

@@ -9,25 +9,24 @@ import { withTranslation } from 'react-i18next';
 import { withUnstatedContainers } from './UnstatedUtils';
 
 import AppContainer from '../services/AppContainer';
-import ApiErrorMessage from './PageManagement/ApiErrorMessage';
+import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
 const EmptyTrashModal = (props) => {
   const {
     t, isOpen, onClose, appContainer,
   } = props;
-  const [errorCode, setErrorCode] = useState(null);
-  const [errorMessage, setErrorMessage] = useState(null);
+
+  const [errs, setErrs] = useState(null);
 
   async function emptyTrash() {
-    setErrorCode(null);
-    setErrorMessage(null);
+    setErrs(null);
+
     try {
       await appContainer.apiv3Delete('/pages/empty-trash');
       window.location.reload();
     }
     catch (err) {
-      setErrorCode(err.code);
-      setErrorMessage(err.message);
+      setErrs(err);
     }
   }
 
@@ -44,7 +43,7 @@ const EmptyTrashModal = (props) => {
         { t('modal_empty.notice')}
       </ModalBody>
       <ModalFooter>
-        <ApiErrorMessage errorCode={errorCode} errorMessage={errorMessage} />
+        <ApiErrorMessageList errs={errs} />
         <button type="button" className="btn btn-danger" onClick={emptyButtonHandler}>
           <i className="icon-trash mr-2" aria-hidden="true"></i> Empty
         </button>

+ 0 - 2
src/client/js/components/Page/PageManagement.jsx

@@ -24,7 +24,6 @@ const PageManagement = (props) => {
 
   const { currentUser } = appContainer;
   const isTopPagePath = isTopPage(path);
-
   const [isPageRenameModalShown, setIsPageRenameModalShown] = useState(false);
   const [isPageDuplicateModalShown, setIsPageDuplicateModalShown] = useState(false);
   const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
@@ -42,7 +41,6 @@ const PageManagement = (props) => {
   function openPageDuplicateModalHandler() {
     setIsPageDuplicateModalShown(true);
   }
-
   function closePageDuplicateModalHandler() {
     setIsPageDuplicateModalShown(false);
   }

+ 6 - 8
src/client/js/components/PageDeleteModal.jsx

@@ -10,7 +10,7 @@ import { withTranslation } from 'react-i18next';
 import { withUnstatedContainers } from './UnstatedUtils';
 import PageContainer from '../services/PageContainer';
 
-import ApiErrorMessage from './PageManagement/ApiErrorMessage';
+import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
 const deleteIconAndKey = {
   completely: {
@@ -32,8 +32,8 @@ const PageDeleteModal = (props) => {
   const [isDeleteRecursively, setIsDeleteRecursively] = useState(true);
   const [isDeleteCompletely, setIsDeleteCompletely] = useState(isDeleteCompletelyModal && isAbleToDeleteCompletely);
   const deleteMode = isDeleteCompletely ? 'completely' : 'temporary';
-  const [errorCode, setErrorCode] = useState(null);
-  const [errorMessage, setErrorMessage] = useState(null);
+
+  const [errs, setErrs] = useState(null);
 
   function changeIsDeleteRecursivelyHandler() {
     setIsDeleteRecursively(!isDeleteRecursively);
@@ -47,8 +47,7 @@ const PageDeleteModal = (props) => {
   }
 
   async function deletePage() {
-    setErrorCode(null);
-    setErrorMessage(null);
+    setErrs(null);
 
     try {
       const response = await pageContainer.deletePage(isDeleteRecursively, isDeleteCompletely);
@@ -56,8 +55,7 @@ const PageDeleteModal = (props) => {
       window.location.href = encodeURI(trashPagePath);
     }
     catch (err) {
-      setErrorCode(err.code);
-      setErrorMessage(err.message);
+      setErrs(err);
     }
   }
 
@@ -124,7 +122,7 @@ const PageDeleteModal = (props) => {
         {!isDeleteCompletelyModal && renderDeleteCompletelyForm()}
       </ModalBody>
       <ModalFooter>
-        <ApiErrorMessage errorCode={errorCode} errorMessage={errorMessage} />
+        <ApiErrorMessageList errs={errs} />
         <button type="button" className={`btn btn-${deleteIconAndKey[deleteMode].color}`} onClick={deleteButtonHandler}>
           <i className={`icon-${deleteIconAndKey[deleteMode].icon}`} aria-hidden="true"></i>
           { t(`modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`) }

+ 119 - 22
src/client/js/components/PageDuplicateModal.jsx

@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React, { useState, useEffect, useCallback } from 'react';
 import PropTypes from 'prop-types';
 
 import {
@@ -6,13 +6,18 @@ import {
 } from 'reactstrap';
 
 import { withTranslation } from 'react-i18next';
-
+import { debounce } from 'throttle-debounce';
 import { withUnstatedContainers } from './UnstatedUtils';
+import { toastError } from '../util/apiNotification';
 
 import AppContainer from '../services/AppContainer';
 import PageContainer from '../services/PageContainer';
 import PagePathAutoComplete from './PagePathAutoComplete';
-import ApiErrorMessage from './PageManagement/ApiErrorMessage';
+import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
+import ComparePathsTable from './ComparePathsTable';
+import DuplicatePathsTable from './DuplicatedPathsTable';
+
+const LIMIT_FOR_LIST = 10;
 
 const PageDuplicateModal = (props) => {
   const { t, appContainer, pageContainer } = props;
@@ -23,16 +28,43 @@ const PageDuplicateModal = (props) => {
   const { crowi } = appContainer.config;
 
   const [pageNameInput, setPageNameInput] = useState(path);
-  const [errorCode, setErrorCode] = useState(null);
-  const [errorMessage, setErrorMessage] = useState(null);
+
+  const [errs, setErrs] = useState(null);
+
+  const [subordinatedPages, setSubordinatedPages] = useState([]);
+  const [isDuplicateRecursively, setIsDuplicateRecursively] = useState(true);
+  const [isDuplicateRecursivelyWithoutExistPath, setIsDuplicateRecursivelyWithoutExistPath] = useState(true);
+  const [existingPaths, setExistingPaths] = useState([]);
+
+  const checkExistPaths = async(newParentPath) => {
+    try {
+      const res = await appContainer.apiv3Get('/page/exist-paths', { fromPath: path, toPath: newParentPath });
+      const { existPaths } = res.data;
+      setExistingPaths(existPaths);
+    }
+    catch (err) {
+      setErrs(err);
+      toastError(t('modal_rename.label.Fail to get exist path'));
+    }
+  };
+
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  const checkExistPathsDebounce = useCallback(
+    debounce(1000, checkExistPaths), [],
+  );
+
+  useEffect(() => {
+    if (pageNameInput !== path) {
+      checkExistPathsDebounce(pageNameInput, subordinatedPages);
+    }
+  }, [pageNameInput, subordinatedPages, path, checkExistPathsDebounce]);
 
   /**
    * change pageNameInput for PagePathAutoComplete
    * @param {string} value
    */
   function ppacInputChangeHandler(value) {
-    setErrorCode(null);
-    setErrorMessage(null);
+    setErrs(null);
     setPageNameInput(value);
   }
 
@@ -41,22 +73,45 @@ const PageDuplicateModal = (props) => {
    * @param {string} value
    */
   function inputChangeHandler(value) {
-    setErrorCode(null);
-    setErrorMessage(null);
+    setErrs(null);
     setPageNameInput(value);
   }
 
+  function changeIsDuplicateRecursivelyHandler() {
+    setIsDuplicateRecursively(!isDuplicateRecursively);
+  }
+
+  const getSubordinatedList = useCallback(async() => {
+    try {
+      const res = await appContainer.apiv3Get('/pages/subordinated-list', { path, limit: LIMIT_FOR_LIST });
+      const { subordinatedPaths } = res.data;
+      setSubordinatedPages(subordinatedPaths);
+    }
+    catch (err) {
+      setErrs(err);
+      toastError(t('modal_duplicate.label.Fail to get subordinated pages'));
+    }
+  }, [appContainer, path, t]);
+
+  useEffect(() => {
+    if (props.isOpen) {
+      getSubordinatedList();
+    }
+  }, [props.isOpen, getSubordinatedList]);
+
+  function changeIsDuplicateRecursivelyWithoutExistPathHandler() {
+    setIsDuplicateRecursivelyWithoutExistPath(!isDuplicateRecursivelyWithoutExistPath);
+  }
+
   async function duplicate() {
+    setErrs(null);
+
     try {
-      setErrorCode(null);
-      setErrorMessage(null);
-      const res = await appContainer.apiPost('/pages.duplicate', { page_id: pageId, new_path: pageNameInput });
-      const { page } = res;
-      window.location.href = encodeURI(`${page.path}?duplicated=${path}`);
+      await appContainer.apiv3Post('/pages/duplicate', { pageId, pageNameInput, isRecursively: isDuplicateRecursively });
+      window.location.href = encodeURI(`${pageNameInput}?duplicated=${path}`);
     }
     catch (err) {
-      setErrorCode(err.code);
-      setErrorMessage(err.message);
+      setErrs(err);
     }
   }
 
@@ -70,9 +125,8 @@ const PageDuplicateModal = (props) => {
         { t('modal_duplicate.label.Duplicate page') }
       </ModalHeader>
       <ModalBody>
-        <div className="form-group">
-          <label>{ t('modal_duplicate.label.Current page name') }</label><br />
-          <code>{ path }</code>
+        <div className="form-group"><label>{t('modal_duplicate.label.Current page name')}</label><br />
+          <code>{path}</code>
         </div>
         <div className="form-group">
           <label htmlFor="duplicatePageName">{ t('modal_duplicate.label.New page name') }</label><br />
@@ -101,13 +155,56 @@ const PageDuplicateModal = (props) => {
             </div>
           </div>
         </div>
+        <div className="custom-control custom-checkbox custom-checkbox-warning mb-3">
+          <input
+            className="custom-control-input"
+            name="recursively"
+            id="cbDuplicateRecursively"
+            type="checkbox"
+            checked={isDuplicateRecursively}
+            onChange={changeIsDuplicateRecursivelyHandler}
+          />
+          <label className="custom-control-label" htmlFor="cbDuplicateRecursively">
+            { t('modal_duplicate.label.Recursively') }
+            <p className="form-text text-muted mt-0">{ t('modal_duplicate.help.recursive') }</p>
+          </label>
+
+          <div>
+            {isDuplicateRecursively && existingPaths.length !== 0 && (
+            <div className="custom-control custom-checkbox custom-checkbox-warning">
+              <input
+                className="custom-control-input"
+                name="withoutExistRecursively"
+                id="cbDuplicatewithoutExistRecursively"
+                type="checkbox"
+                checked={isDuplicateRecursivelyWithoutExistPath}
+                onChange={changeIsDuplicateRecursivelyWithoutExistPathHandler}
+              />
+              <label className="custom-control-label" htmlFor="cbDuplicatewithoutExistRecursively">
+                { t('modal_duplicate.label.Duplicate without exist path') }
+              </label>
+            </div>
+            )}
+          </div>
+          <div>
+            {isDuplicateRecursively && <ComparePathsTable subordinatedPages={subordinatedPages} newPagePath={pageNameInput} />}
+            {isDuplicateRecursively && existingPaths.length !== 0 && <DuplicatePathsTable existingPaths={existingPaths} oldPagePath={pageNameInput} />}
+          </div>
+        </div>
+
       </ModalBody>
       <ModalFooter>
-        <ApiErrorMessage errorCode={errorCode} errorMessage={errorMessage} targetPath={pageNameInput} />
-        <button type="button" className="btn btn-primary" onClick={duplicate}>Duplicate page</button>
+        <ApiErrorMessageList errs={errs} targetPath={pageNameInput} />
+        <button
+          type="button"
+          className="btn btn-primary"
+          onClick={duplicate}
+          disabled={(isDuplicateRecursively && !isDuplicateRecursivelyWithoutExistPath && existingPaths.length !== 0)}
+        >
+          { t('modal_duplicate.label.Duplicate page') }
+        </button>
       </ModalFooter>
     </Modal>
-
   );
 };
 

+ 23 - 0
src/client/js/components/PageManagement/ApiErrorMessageList.jsx

@@ -0,0 +1,23 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import ApiErrorMessage from './ApiErrorMessage';
+import toArrayIfNot from '../../../../lib/util/toArrayIfNot';
+
+function ApiErrorMessageList(props) {
+  const errs = toArrayIfNot(props.errs);
+
+  return (
+    <>
+      {errs.map(err => <ApiErrorMessage key={err.code} errorCode={err.code} errorMessage={err.message} targetPath={props.targetPath} />)}
+    </>
+  );
+
+}
+
+ApiErrorMessageList.propTypes = {
+  errs:         PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
+  targetPath:   PropTypes.string,
+};
+
+export default ApiErrorMessageList;

+ 96 - 15
src/client/js/components/PageRenameModal.jsx

@@ -1,4 +1,6 @@
-import React, { useState } from 'react';
+import React, {
+  useState, useEffect, useCallback,
+} from 'react';
 import PropTypes from 'prop-types';
 
 import {
@@ -7,11 +9,16 @@ import {
 
 import { withTranslation } from 'react-i18next';
 
+import { debounce } from 'throttle-debounce';
 import { withUnstatedContainers } from './UnstatedUtils';
+import { toastError } from '../util/apiNotification';
 
 import AppContainer from '../services/AppContainer';
 import PageContainer from '../services/PageContainer';
-import ApiErrorMessage from './PageManagement/ApiErrorMessage';
+import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
+import ComparePathsTable from './ComparePathsTable';
+import DuplicatedPathsTable from './DuplicatedPathsTable';
+
 
 const PageRenameModal = (props) => {
   const {
@@ -23,17 +30,25 @@ const PageRenameModal = (props) => {
   const { crowi } = appContainer.config;
 
   const [pageNameInput, setPageNameInput] = useState(path);
-  const [errorCode, setErrorCode] = useState(null);
-  const [errorMessage, setErrorMessage] = useState(null);
 
+  const [errs, setErrs] = useState(null);
+
+  const [subordinatedPages, setSubordinatedPages] = useState([]);
+  const [existingPaths, setExistingPaths] = useState([]);
   const [isRenameRecursively, SetIsRenameRecursively] = useState(true);
   const [isRenameRedirect, SetIsRenameRedirect] = useState(false);
   const [isRenameMetadata, SetIsRenameMetadata] = useState(false);
+  const [subordinatedError] = useState(null);
+  const [isRenameRecursivelyWithoutExistPath, setIsRenameRecursivelyWithoutExistPath] = useState(true);
 
   function changeIsRenameRecursivelyHandler() {
     SetIsRenameRecursively(!isRenameRecursively);
   }
 
+  function changeIsRenameRecursivelyWithoutExistPathHandler() {
+    setIsRenameRecursivelyWithoutExistPath(!isRenameRecursivelyWithoutExistPath);
+  }
+
   function changeIsRenameRedirectHandler() {
     SetIsRenameRedirect(!isRenameRedirect);
   }
@@ -42,21 +57,61 @@ const PageRenameModal = (props) => {
     SetIsRenameMetadata(!isRenameMetadata);
   }
 
+  const updateSubordinatedList = useCallback(async() => {
+    try {
+      const res = await appContainer.apiv3Get('/pages/subordinated-list', { path });
+      const { subordinatedPaths } = res.data;
+      setSubordinatedPages(subordinatedPaths);
+    }
+    catch (err) {
+      setErrs(err);
+      toastError(t('modal_rename.label.Fail to get subordinated pages'));
+    }
+  }, [appContainer, path, t]);
+
+  useEffect(() => {
+    if (props.isOpen) {
+      updateSubordinatedList();
+    }
+  }, [props.isOpen, updateSubordinatedList]);
+
+
+  const checkExistPaths = async(newParentPath) => {
+    try {
+      const res = await appContainer.apiv3Get('/page/exist-paths', { fromPath: path, toPath: newParentPath });
+      const { existPaths } = res.data;
+      setExistingPaths(existPaths);
+    }
+    catch (err) {
+      setErrs(err);
+      toastError(t('modal_rename.label.Fail to get exist path'));
+    }
+  };
+
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  const checkExistPathsDebounce = useCallback(
+    debounce(1000, checkExistPaths), [],
+  );
+
+  useEffect(() => {
+    if (pageNameInput !== path) {
+      checkExistPathsDebounce(pageNameInput, subordinatedPages);
+    }
+  }, [pageNameInput, subordinatedPages, path, checkExistPathsDebounce]);
+
   /**
    * change pageNameInput
    * @param {string} value
    */
   function inputChangeHandler(value) {
-    setErrorCode(null);
-    setErrorMessage(null);
+    setErrs(null);
     setPageNameInput(value);
   }
 
   async function rename() {
-    try {
-      setErrorCode(null);
-      setErrorMessage(null);
+    setErrs(null);
 
+    try {
       const response = await pageContainer.rename(
         pageNameInput,
         isRenameRecursively,
@@ -64,7 +119,7 @@ const PageRenameModal = (props) => {
         isRenameMetadata,
       );
 
-      const { page } = response;
+      const { page } = response.data;
       const url = new URL(page.path, 'https://dummy');
       url.searchParams.append('renamedFrom', path);
       if (isRenameRedirect) {
@@ -74,13 +129,12 @@ const PageRenameModal = (props) => {
       window.location.href = `${url.pathname}${url.search}`;
     }
     catch (err) {
-      setErrorCode(err.code);
-      setErrorMessage(err.message);
+      setErrs(err);
     }
   }
 
   return (
-    <Modal size="lg" isOpen={props.isOpen} toggle={props.onClose} className="grw-create-page">
+    <Modal size="lg" isOpen={props.isOpen} toggle={props.onClose}>
       <ModalHeader tag="h4" toggle={props.onClose} className="bg-primary text-light">
         { t('modal_rename.label.Move/Rename page') }
       </ModalHeader>
@@ -119,6 +173,26 @@ const PageRenameModal = (props) => {
             { t('modal_rename.label.Recursively') }
             <p className="form-text text-muted mt-0">{ t('modal_rename.help.recursive') }</p>
           </label>
+          {existingPaths.length !== 0 && (
+          <div
+            className="custom-control custom-checkbox custom-checkbox-warning"
+            style={{ display: isRenameRecursively ? '' : 'none' }}
+          >
+            <input
+              className="custom-control-input"
+              name="withoutExistRecursively"
+              id="cbRenamewithoutExistRecursively"
+              type="checkbox"
+              checked={isRenameRecursivelyWithoutExistPath}
+              onChange={changeIsRenameRecursivelyWithoutExistPathHandler}
+            />
+            <label className="custom-control-label" htmlFor="cbRenamewithoutExistRecursively">
+              { t('modal_rename.label.Rename without exist path') }
+            </label>
+          </div>
+          )}
+          {isRenameRecursively && <ComparePathsTable subordinatedPages={subordinatedPages} newPagePath={pageNameInput} />}
+          {isRenameRecursively && existingPaths.length !== 0 && <DuplicatedPathsTable existingPaths={existingPaths} oldPagePath={pageNameInput} />}
         </div>
 
         <div className="custom-control custom-checkbox custom-checkbox-success">
@@ -150,10 +224,17 @@ const PageRenameModal = (props) => {
             <p className="form-text text-muted mt-0">{ t('modal_rename.help.metadata') }</p>
           </label>
         </div>
+        <div> {subordinatedError} </div>
       </ModalBody>
       <ModalFooter>
-        <ApiErrorMessage errorCode={errorCode} errorMessage={errorMessage} targetPath={pageNameInput} />
-        <button type="button" className="btn btn-primary" onClick={rename}>Rename</button>
+        <ApiErrorMessageList errs={errs} targetPath={pageNameInput} />
+        <button
+          type="button"
+          className="btn btn-primary"
+          onClick={rename}
+          disabled={(isRenameRecursively && !isRenameRecursivelyWithoutExistPath && existingPaths.length !== 0)}
+        >Rename
+        </button>
       </ModalFooter>
     </Modal>
   );

+ 5 - 8
src/client/js/components/PutbackPageModal.jsx

@@ -11,15 +11,14 @@ import { withUnstatedContainers } from './UnstatedUtils';
 
 import PageContainer from '../services/PageContainer';
 
-import ApiErrorMessage from './PageManagement/ApiErrorMessage';
+import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
 const PutBackPageModal = (props) => {
   const {
     t, isOpen, onClose, pageContainer, path,
   } = props;
 
-  const [errorCode, setErrorCode] = useState(null);
-  const [errorMessage, setErrorMessage] = useState(null);
+  const [errs, setErrs] = useState(null);
 
   const [isPutbackRecursively, setIsPutbackRecursively] = useState(true);
 
@@ -28,8 +27,7 @@ const PutBackPageModal = (props) => {
   }
 
   async function putbackPage() {
-    setErrorCode(null);
-    setErrorMessage(null);
+    setErrs(null);
 
     try {
       const response = await pageContainer.revertRemove(isPutbackRecursively);
@@ -37,8 +35,7 @@ const PutBackPageModal = (props) => {
       window.location.href = encodeURI(putbackPagePath);
     }
     catch (err) {
-      setErrorCode(err.code);
-      setErrorMessage(err.message);
+      setErrs(err);
     }
   }
 
@@ -73,7 +70,7 @@ const PutBackPageModal = (props) => {
         </div>
       </ModalBody>
       <ModalFooter>
-        <ApiErrorMessage errorCode={errorCode} errorMessage={errorMessage} />
+        <ApiErrorMessageList errs={errs} />
         <button type="button" className="btn btn-info" onClick={putbackPageButtonHandler}>
           <i className="icon-action-undo mr-2" aria-hidden="true"></i> { t('Put Back') }
         </button>

+ 14 - 16
src/client/js/services/PageContainer.js

@@ -468,11 +468,10 @@ export default class PageContainer extends Container {
       body: markdown,
     });
 
-    const res = await this.appContainer.apiPost('/pages.create', params);
-    if (!res.ok) {
-      throw new Error(res.error);
-    }
-    return res;
+    const res = await this.appContainer.apiv3Post('/pages/', params);
+    const { page, tags } = res.data;
+
+    return { page, tags };
   }
 
   async updatePage(pageId, revisionId, markdown, tmpParams) {
@@ -523,19 +522,18 @@ export default class PageContainer extends Container {
     });
   }
 
-  rename(pageNameInput, isRenameRecursively, isRenameRedirect, isRenameMetadata) {
+  rename(newPagePath, isRecursively, isRenameRedirect, isRemainMetadata) {
     const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
-    const isRecursively = isRenameRecursively ? true : null;
-    const isRedirect = isRenameRedirect ? true : null;
-    const isRemain = isRenameMetadata ? true : null;
+    const { pageId, revisionId, path } = this.state;
 
-    return this.appContainer.apiPost('/pages.rename', {
-      recursively: isRecursively,
-      page_id: this.state.pageId,
-      revision_id: this.state.revisionId,
-      new_path: pageNameInput,
-      create_redirect: isRedirect,
-      remain_metadata: isRemain,
+    return this.appContainer.apiv3Put('/pages/rename', {
+      revisionId,
+      pageId,
+      isRecursively,
+      isRenameRedirect,
+      isRemainMetadata,
+      newPagePath,
+      path,
       socketClientId: socketIoContainer.getSocketClientId(),
     });
   }

+ 9 - 0
src/client/styles/scss/_page-duplicate-modal.scss

@@ -0,0 +1,9 @@
+.grw-duplicate-page {
+  .duplicate-name {
+    list-style: none;
+  }
+
+  .duplicate-exist {
+    color: #c7254e;
+  }
+}

+ 5 - 0
src/client/styles/scss/molecules/compare-paths-table.scss

@@ -0,0 +1,5 @@
+.grw-compare-paths-table {
+  tbody {
+    max-height: 200px;
+  }
+}

+ 5 - 0
src/client/styles/scss/molecules/duplicated-paths-table.scss

@@ -0,0 +1,5 @@
+.grw-duplicated-paths-table {
+  tbody {
+    max-height: 200px;
+  }
+}

+ 3 - 0
src/client/styles/scss/style-app.scss

@@ -25,6 +25,8 @@
 @import 'molecules/copy-dropdown';
 @import 'molecules/page-editor-mode-manager';
 @import 'molecules/slack-notification';
+@import 'molecules/duplicated-paths-table.scss';
+@import 'molecules/compare-paths-table.scss';
 
 // growi component
 @import 'admin';
@@ -45,6 +47,7 @@
 @import 'modal';
 @import 'navbar';
 @import 'on-edit';
+@import 'page-duplicate-modal';
 @import 'page_list';
 @import 'page-accessories-control';
 @import 'page-accessories-modal';

+ 19 - 0
src/lib/util/path-utils.js

@@ -1,3 +1,5 @@
+const escapeStringRegexp = require('escape-string-regexp');
+
 /**
  * Whether path is the top page
  * @param {string} path
@@ -47,9 +49,26 @@ const userPageRoot = (user) => {
   return `/user/${user.username}`;
 };
 
+/**
+ * return user path
+ * @param {string} parentPath
+ * @param {string} childPath
+ * @param {string} newPath
+ *
+ * @return {string}
+ */
+const convertToNewAffiliationPath = (oldPath, newPath, childPath) => {
+  if (newPath === null) {
+    throw new Error('Please input the new page path');
+  }
+  const pathRegExp = new RegExp(`^${escapeStringRegexp(oldPath)}`, 'i');
+  return childPath.replace(pathRegExp, newPath);
+};
+
 module.exports = {
   isTopPage,
   isTrashPage,
   isUserPage,
   userPageRoot,
+  convertToNewAffiliationPath,
 };

+ 15 - 0
src/lib/util/to-array-from-csv.js

@@ -0,0 +1,15 @@
+// converts csv item to array
+const toArrayFromCsv = (text) => {
+  let array = [];
+
+  if (text == null) {
+    return array;
+  }
+
+  array = text.split(',').map(el => el.trim());
+  array = array.filter(el => el !== '');
+
+  return array;
+};
+
+module.exports = toArrayFromCsv;

+ 16 - 0
src/server/crowi/index.js

@@ -41,6 +41,7 @@ function Crowi(rootdir) {
   this.mailService = null;
   this.passportService = null;
   this.globalNotificationService = null;
+  this.userNotificationService = null;
   this.slackNotificationService = null;
   this.xssService = null;
   this.aclService = null;
@@ -120,6 +121,7 @@ Crowi.prototype.init = async function() {
   // globalNotification depends on slack and mailer
   await Promise.all([
     this.setUpGlobalNotification(),
+    this.setUpUserNotification(),
   ]);
 };
 
@@ -316,6 +318,10 @@ Crowi.prototype.getGlobalNotificationService = function() {
   return this.globalNotificationService;
 };
 
+Crowi.prototype.getUserNotificationService = function() {
+  return this.userNotificationService;
+};
+
 Crowi.prototype.getRestQiitaAPIService = function() {
   return this.restQiitaAPIService;
 };
@@ -481,6 +487,16 @@ Crowi.prototype.setUpGlobalNotification = async function() {
   }
 };
 
+/**
+ * setup UserNotificationService
+ */
+Crowi.prototype.setUpUserNotification = async function() {
+  const UserNotificationService = require('../service/user-notification');
+  if (this.userNotificationService == null) {
+    this.userNotificationService = new UserNotificationService(this);
+  }
+};
+
 /**
  * setup SlackNotificationService
  */

+ 25 - 2
src/server/models/page.js

@@ -258,6 +258,17 @@ class PageQueryBuilder {
     return this;
   }
 
+  addConditionToListByPathsArray(paths) {
+    this.query = this.query
+      .and({
+        path: {
+          $in: paths,
+        },
+      });
+
+    return this;
+  }
+
   populateDataToList(userPublicFields) {
     this.query = this.query
       .populate({
@@ -1283,12 +1294,24 @@ module.exports = function(crowi) {
     // find manageable descendants
     const pages = await this.findManageableListWithDescendants(targetPage, user, options);
 
-    await Promise.all(pages.map((page) => {
+    // TODO GW-4634 use stream
+    const promise = pages.map((page) => {
       const newPagePath = page.path.replace(pathRegExp, newPagePathPrefix);
       return this.rename(page, newPagePath, user, options);
-    }));
+    });
+
+    await Promise.allSettled(promise);
+
     targetPage.path = newPagePathPrefix;
     return targetPage;
+
+  };
+
+  pageSchema.statics.findListByPathsArray = async function(paths) {
+    const queryBuilder = new PageQueryBuilder(this.find());
+    queryBuilder.addConditionToListByPathsArray(paths);
+
+    return await queryBuilder.query.exec();
   };
 
   // TODO: transplant to service/page.js because page deletion affects various models data

+ 2 - 2
src/server/routes/apiv3/notification-setting.js

@@ -229,7 +229,7 @@ module.exports = (crowi) => {
         createdUser: await UpdatePost.create(pathPattern, channel, req.user),
         userNotifications: await UpdatePost.findAll(),
       };
-      return res.apiv3({ responseParams });
+      return res.apiv3({ responseParams }, 201);
     }
     catch (err) {
       const msg = 'Error occurred in updating user notification';
@@ -326,7 +326,7 @@ module.exports = (crowi) => {
 
     try {
       const createdNotification = await notification.save();
-      return res.apiv3({ createdNotification });
+      return res.apiv3({ createdNotification }, 201);
     }
     catch (err) {
       const msg = 'Error occurred in updating global notification';

+ 62 - 0
src/server/routes/apiv3/page.js

@@ -7,6 +7,7 @@ const { body, query } = require('express-validator');
 
 const router = express.Router();
 
+const { convertToNewAffiliationPath } = require('../../../lib/util/path-utils');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 
@@ -154,6 +155,10 @@ module.exports = (crowi) => {
       body('hierarchyType').isString().isIn(['allSubordinatedPage', 'decideHierarchy']),
       body('hierarchyValue').isNumeric(),
     ],
+    exist: [
+      query('fromPath').isString(),
+      query('toPath').isString(),
+    ],
   };
 
   /**
@@ -315,6 +320,63 @@ module.exports = (crowi) => {
     return stream.pipe(res);
   });
 
+  /**
+   * @swagger
+   *
+   *    /page/exist-paths:
+   *      get:
+   *        tags: [Page]
+   *        summary: /page/exist-paths
+   *        description: Get already exist paths
+   *        operationId: getAlreadyExistPaths
+   *        parameters:
+   *          - name: fromPath
+   *            in: query
+   *            description: old parent path
+   *            schema:
+   *              type: string
+   *          - name: toPath
+   *            in: query
+   *            description: new parent path
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: Succeeded to retrieve pages.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    existPaths:
+   *                      type: object
+   *                      description: Paths are already exist in DB
+   *          500:
+   *            description: Internal server error.
+   */
+  router.get('/exist-paths', loginRequired, validator.exist, apiV3FormValidator, async(req, res) => {
+    const { fromPath, toPath } = req.query;
+
+    try {
+      const fromPage = await Page.findByPath(fromPath);
+      const fromPageDescendants = await Page.findManageableListWithDescendants(fromPage, req.user);
+
+      const toPathDescendantsArray = fromPageDescendants.map((subordinatedPage) => {
+        return convertToNewAffiliationPath(fromPath, toPath, subordinatedPage.path);
+      });
+
+      const existPages = await Page.findListByPathsArray(toPathDescendantsArray);
+      const existPaths = existPages.map(page => page.path);
+
+      return res.apiv3({ existPaths });
+
+    }
+    catch (err) {
+      logger.error('Failed to get exist path', err);
+      return res.apiv3Err(err, 500);
+    }
+
+  });
+
   // TODO GW-2746 bulk export pages
   // /**
   //  * @swagger

+ 500 - 18
src/server/routes/apiv3/pages.js

@@ -2,26 +2,266 @@ const loggerFactory = require('@alias/logger');
 
 const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
 const express = require('express');
+const pathUtils = require('growi-commons').pathUtils;
 
-const router = express.Router();
+const { body } = require('express-validator/check');
 const { query } = require('express-validator');
+const ErrorV3 = require('../../models/vo/error-apiv3');
+
+const router = express.Router();
+
+const LIMIT_FOR_LIST = 10;
 
 /**
  * @swagger
  *  tags:
  *    name: Pages
  */
+
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      Page:
+ *        description: Page
+ *        type: object
+ *        properties:
+ *          _id:
+ *            type: string
+ *            description: page ID
+ *            example: 5e07345972560e001761fa63
+ *          __v:
+ *            type: number
+ *            description: DB record version
+ *            example: 0
+ *          commentCount:
+ *            type: number
+ *            description: count of comments
+ *            example: 3
+ *          createdAt:
+ *            type: string
+ *            description: date created at
+ *            example: 2010-01-01T00:00:00.000Z
+ *          creator:
+ *            $ref: '#/components/schemas/User'
+ *          extended:
+ *            type: object
+ *            description: extend data
+ *            example: {}
+ *          grant:
+ *            type: number
+ *            description: grant
+ *            example: 1
+ *          grantedUsers:
+ *            type: array
+ *            description: granted users
+ *            items:
+ *              type: string
+ *              description: user ID
+ *            example: ["5ae5fccfc5577b0004dbd8ab"]
+ *          lastUpdateUser:
+ *            $ref: '#/components/schemas/User'
+ *          liker:
+ *            type: array
+ *            description: granted users
+ *            items:
+ *              type: string
+ *              description: user ID
+ *            example: []
+ *          path:
+ *            type: string
+ *            description: page path
+ *            example: /
+ *          redirectTo:
+ *            type: string
+ *            description: redirect path
+ *            example: ""
+ *          revision:
+ *            type: string
+ *            description: revision ID
+ *            example: ["5ae5fccfc5577b0004dbd8ab"]
+ *          seenUsers:
+ *            type: array
+ *            description: granted users
+ *            items:
+ *              type: string
+ *              description: user ID
+ *            example: ["5ae5fccfc5577b0004dbd8ab"]
+ *          status:
+ *            type: string
+ *            description: status
+ *            enum:
+ *              - 'wip'
+ *              - 'published'
+ *              - 'deleted'
+ *              - 'deprecated'
+ *            example: published
+ *          updatedAt:
+ *            type: string
+ *            description: date updated at
+ *            example: 2010-01-01T00:00:00.000Z
+ */
+
 module.exports = (crowi) => {
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const loginRequired = require('../../middlewares/login-required')(crowi, true);
+  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
   const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
-
   const Page = crowi.model('Page');
+  const PageTagRelation = crowi.model('PageTagRelation');
+  const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
+
+  const globalNotificationService = crowi.getGlobalNotificationService();
+  const userNotificationService = crowi.getUserNotificationService();
+
+  const { serializePageSecurely } = require('../../models/serializers/page-serializer');
+
+  const validator = {
+    createPage: [
+      body('body').exists().not().isEmpty({ ignore_whitespace: true })
+        .withMessage('body is required'),
+      body('path').exists().not().isEmpty({ ignore_whitespace: true })
+        .withMessage('path is required'),
+      body('grant').if(value => value != null).isInt({ min: 0, max: 5 }).withMessage('grant must be integer from 1 to 5'),
+      body('overwriteScopesOfDescendants').if(value => value != null).isBoolean().withMessage('overwriteScopesOfDescendants must be boolean'),
+      body('isSlackEnabled').if(value => value != null).isBoolean().withMessage('isSlackEnabled must be boolean'),
+      body('slackChannels').if(value => value != null).isString().withMessage('slackChannels must be string'),
+      body('socketClientId').if(value => value != null).isInt().withMessage('socketClientId must be int'),
+      body('pageTags').if(value => value != null).isArray().withMessage('pageTags must be array'),
+    ],
+    renamePage: [
+      body('pageId').isMongoId().withMessage('pageId is required'),
+      body('revisionId').isMongoId().withMessage('revisionId is required'),
+      body('newPagePath').isLength({ min: 1 }).withMessage('newPagePath is required'),
+      body('isRenameRedirect').if(value => value != null).isBoolean().withMessage('isRenameRedirect must be boolean'),
+      body('isRemainMetadata').if(value => value != null).isBoolean().withMessage('isRemainMetadata must be boolean'),
+      body('isRecursively').if(value => value != null).isBoolean().withMessage('isRecursively must be boolean'),
+      body('socketClientId').if(value => value != null).isInt().withMessage('socketClientId must be int'),
+    ],
+
+    duplicatePage: [
+      body('pageId').isMongoId().withMessage('pageId is required'),
+      body('pageNameInput').trim().isLength({ min: 1 }).withMessage('pageNameInput is required'),
+      body('isRecursively').if(value => value != null).isBoolean().withMessage('isRecursively must be boolean'),
+    ],
+  };
+
+  async function createPageAction({
+    path, body, user, options,
+  }) {
+    const createdPage = Page.create(path, body, user, options);
+    return createdPage;
+  }
+
+  async function saveTagsAction({ createdPage, pageTags }) {
+    if (pageTags != null) {
+      await PageTagRelation.updatePageTags(createdPage.id, pageTags);
+      return PageTagRelation.listTagNamesByPage(createdPage.id);
+    }
+
+    return [];
+  }
+
+  /**
+   * @swagger
+   *
+   *    /pages/create:
+   *      post:
+   *        tags: [Pages]
+   *        operationId: createPage
+   *        description: Create page
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  body:
+   *                    type: string
+   *                    description: Text of page
+   *                  path:
+   *                    $ref: '#/components/schemas/Page/properties/path'
+   *                  grant:
+   *                    $ref: '#/components/schemas/Page/properties/grant'
+   *                required:
+   *                  - body
+   *                  - path
+   *        responses:
+   *          201:
+   *            description: Succeeded to create page.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    page:
+   *                      $ref: '#/components/schemas/Page'
+   *          409:
+   *            description: page path is already existed
+   */
+  router.post('/', accessTokenParser, loginRequiredStrictly, csrf, validator.createPage, apiV3FormValidator, async(req, res) => {
+    const {
+      body, grant, grantUserGroupId, overwriteScopesOfDescendants, isSlackEnabled, slackChannels, socketClientId, pageTags,
+    } = req.body;
+
+    let { path } = req.body;
+
+    // check whether path starts slash
+    path = pathUtils.addHeadingSlash(path);
+
+    // check page existence
+    const isExist = await Page.count({ path }) > 0;
+    if (isExist) {
+      return res.apiv3Err(new ErrorV3('Failed to post page', 'page_exists'), 500);
+    }
+
+    const options = { socketClientId };
+    if (grant != null) {
+      options.grant = grant;
+      options.grantUserGroupId = grantUserGroupId;
+    }
+
+    const createdPage = await createPageAction({
+      path, body, user: req.user, options,
+    });
+
+    const savedTags = await saveTagsAction({ createdPage, pageTags });
+
+    const result = { page: serializePageSecurely(createdPage), tags: savedTags };
+
+    // update scopes for descendants
+    if (overwriteScopesOfDescendants) {
+      Page.applyScopesToDescendantsAsyncronously(createdPage, req.user);
+    }
+
+    try {
+      // global notification
+      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, createdPage, req.user);
+    }
+    catch (err) {
+      logger.error('Create grobal notification failed', err);
+    }
+
+    // user notification
+    if (isSlackEnabled) {
+      try {
+        const results = await userNotificationService.fire(createdPage, req.user, slackChannels, 'create', false);
+        results.forEach((result) => {
+          if (result.status === 'rejected') {
+            logger.error('Create user notification failed', result.reason);
+          }
+        });
+      }
+      catch (err) {
+        logger.error('Create user notification failed', err);
+      }
+    }
+
+    return res.apiv3(result, 201);
+  });
 
-  const validator = {};
 
   /**
    * @swagger
@@ -57,32 +297,146 @@ module.exports = (crowi) => {
       return res.apiv3(result);
     }
     catch (err) {
-      res.code = 'unknown';
       logger.error('Failed to get recent pages', err);
-      return res.apiv3Err(err, 500);
+      return res.apiv3Err(new ErrorV3('Failed to get recent pages', 'unknown'), 500);
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *
+   *    /pages/rename:
+   *      post:
+   *        tags: [Pages]
+   *        operationId: renamePage
+   *        description: Rename page
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  pageId:
+   *                    $ref: '#/components/schemas/Page/properties/_id'
+   *                  path:
+   *                    $ref: '#/components/schemas/Page/properties/path'
+   *                  revisionId:
+   *                    type: string
+   *                    description: revision ID
+   *                    example: 5e07345972560e001761fa63
+   *                  newPagePath:
+   *                    type: string
+   *                    description: new path
+   *                    example: /user/alice/new_test
+   *                  isRenameRedirect:
+   *                    type: boolean
+   *                    description: whether redirect page
+   *                  isRemainMetadata:
+   *                    type: boolean
+   *                    description: whether remain meta data
+   *                  isRecursively:
+   *                    type: boolean
+   *                    description: whether rename page with descendants
+   *                required:
+   *                  - pageId
+   *                  - revisionId
+   *        responses:
+   *          200:
+   *            description: Succeeded to rename page.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    page:
+   *                      $ref: '#/components/schemas/Page'
+   *          401:
+   *            description: page id is invalid
+   *          409:
+   *            description: page path is already existed
+   */
+  router.put('/rename', accessTokenParser, loginRequiredStrictly, csrf, validator.renamePage, apiV3FormValidator, async(req, res) => {
+    const { pageId, isRecursively, revisionId } = req.body;
+
+    let newPagePath = pathUtils.normalizePath(req.body.newPagePath);
+
+    const options = {
+      createRedirectPage: req.body.isRenameRedirect,
+      updateMetadata: !req.body.isRemainMetadata,
+      socketClientId: +req.body.socketClientId || undefined,
+    };
+
+    if (!Page.isCreatableName(newPagePath)) {
+      return res.apiv3Err(new ErrorV3(`Could not use the path '${newPagePath})'`, 'invalid_path'), 409);
+    }
+
+    // check whether path starts slash
+    newPagePath = pathUtils.addHeadingSlash(newPagePath);
+
+    const isExist = await Page.count({ path: newPagePath }) > 0;
+    if (isExist) {
+      // if page found, cannot cannot rename to that path
+      return res.apiv3Err(new ErrorV3(`${newPagePath} already exists`, 'already_exists'), 409);
+    }
+
+    let page;
+
+    try {
+      page = await Page.findByIdAndViewer(pageId, req.user);
+
+      if (page == null) {
+        return res.apiv3Err(new ErrorV3(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 401);
+      }
+
+      if (!page.isUpdatable(revisionId)) {
+        return res.apiv3Err(new ErrorV3('Someone could update this page, so couldn\'t delete.', 'notfound_or_forbidden'), 409);
+      }
+
+      if (isRecursively) {
+        page = await Page.renameRecursively(page, newPagePath, req.user, options);
+      }
+      else {
+        page = await Page.rename(page, newPagePath, req.user, options);
+      }
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
     }
+
+    const result = { page: serializePageSecurely(page) };
+
+    try {
+      // global notification
+      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_MOVE, page, req.user, {
+        oldPath: req.body.path,
+      });
+    }
+    catch (err) {
+      logger.error('Move notification failed', err);
+    }
+
+    return res.apiv3(result);
   });
 
+
   /**
-  * @swagger
-  *
-  *    /pages/empty-trash:
-  *      delete:
-  *        tags: [Pages]
-  *        description: empty trash
-  *        responses:
-  *          200:
-  *            description: Succeeded to remove all trash pages
-  */
+   * @swagger
+   *
+   *    /pages/empty-trash:
+   *      delete:
+   *        tags: [Pages]
+   *        description: empty trash
+   *        responses:
+   *          200:
+   *            description: Succeeded to remove all trash pages
+   */
   router.delete('/empty-trash', loginRequired, adminRequired, csrf, async(req, res) => {
     try {
       const pages = await Page.completelyDeletePageRecursively({ path: '/trash' }, req.user);
       return res.apiv3({ pages });
     }
     catch (err) {
-      res.code = 'unknown';
-      logger.error('Failed to delete trash pages', err);
-      return res.apiv3Err(err, 500);
+      return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
     }
   });
 
@@ -120,5 +474,133 @@ module.exports = (crowi) => {
     }
   });
 
+  /**
+   * @swagger
+   *
+   *
+   *    /pages/duplicate:
+   *      post:
+   *        tags: [Pages]
+   *        operationId: duplicatePage
+   *        description: Duplicate page
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  pageId:
+   *                    $ref: '#/components/schemas/Page/properties/_id'
+   *                  pageNameInput:
+   *                    $ref: '#/components/schemas/Page/properties/path'
+   *                  isRecursively:
+   *                    type: boolean
+   *                    description: whether duplicate page with descendants
+   *                required:
+   *                  - pageId
+   *        responses:
+   *          200:
+   *            description: Succeeded to duplicate page.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    page:
+   *                      $ref: '#/components/schemas/Page'
+   *
+   *          403:
+   *            description: Forbidden to duplicate page.
+   *          500:
+   *            description: Internal server error.
+   */
+  router.post('/duplicate', accessTokenParser, loginRequiredStrictly, csrf, validator.duplicatePage, apiV3FormValidator, async(req, res) => {
+    const { pageId, isRecursively } = req.body;
+
+    const newPagePath = pathUtils.normalizePath(req.body.pageNameInput);
+
+    // check page existence
+    const isExist = (await Page.count({ path: newPagePath })) > 0;
+    if (isExist) {
+      return res.apiv3Err(new ErrorV3(`Page exists '${newPagePath})'`, 'already_exists'), 409);
+    }
+
+    const page = await Page.findByIdAndViewer(pageId, req.user);
+
+    // null check
+    if (page == null) {
+      res.code = 'Page is not found';
+      logger.error('Failed to find the pages');
+      return res.apiv3Err(new ErrorV3('Not Founded the page', 'notfound_or_forbidden'), 404);
+    }
+
+    let newParentPage;
+
+    if (isRecursively) {
+      newParentPage = await crowi.pageService.duplicateRecursively(page, newPagePath, req.user);
+    }
+    else {
+      newParentPage = await crowi.pageService.duplicate(page, newPagePath, req.user);
+    }
+
+    const result = { page: serializePageSecurely(newParentPage) };
+
+    page.path = newPagePath;
+    try {
+      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, page, req.user);
+    }
+    catch (err) {
+      logger.error('Create grobal notification failed', err);
+    }
+
+    return res.apiv3(result);
+  });
+
+  /**
+   * @swagger
+   *
+   *
+   *    /pages/subordinated-list:
+   *      get:
+   *        tags: [Pages]
+   *        operationId: subordinatedList
+   *        description: Get subordinated pages
+   *        parameters:
+   *          - name: path
+   *            in: query
+   *            description: Parent path of search
+   *            schema:
+   *              type: string
+   *          - name: limit
+   *            in: query
+   *            description: Limit of acquisitions
+   *            schema:
+   *              type: number
+   *        responses:
+   *          200:
+   *            description: Succeeded to retrieve pages.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    subordinatedPaths:
+   *                      type: object
+   *                      description: descendants page
+   *          500:
+   *            description: Internal server error.
+   */
+  router.get('/subordinated-list', accessTokenParser, loginRequired, async(req, res) => {
+    const { path } = req.query;
+    const limit = parseInt(req.query.limit) || LIMIT_FOR_LIST;
+
+    try {
+      const pageData = await Page.findByPath(path);
+      const result = await Page.findManageableListWithDescendants(pageData, req.user, { limit });
+
+      return res.apiv3({ subordinatedPaths: result });
+    }
+    catch (err) {
+      return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
+    }
+
+  });
   return router;
 };

+ 2 - 2
src/server/routes/apiv3/response.js

@@ -4,13 +4,13 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
 
 const addCustomFunctionToResponse = (express, crowi) => {
 
-  express.response.apiv3 = function(obj = {}) { // not arrow function
+  express.response.apiv3 = function(obj = {}, status = 200) { // not arrow function
     // obj must be object
     if (typeof obj !== 'object' || obj instanceof Array) {
       throw new Error('invalid value supplied to res.apiv3');
     }
 
-    this.json({ data: obj });
+    this.status(status).json({ data: obj });
   };
 
   express.response.apiv3Err = function(_err, status = 400, info) { // not arrow function

+ 1 - 1
src/server/routes/apiv3/share-links.js

@@ -107,7 +107,7 @@ module.exports = (crowi) => {
 
     try {
       const postedShareLink = await ShareLink.create({ relatedPage, expiredAt, description });
-      return res.apiv3(postedShareLink);
+      return res.apiv3(postedShareLink, 201);
     }
     catch (err) {
       const msg = 'Error occured in post share link';

+ 1 - 1
src/server/routes/apiv3/user-group.js

@@ -116,7 +116,7 @@ module.exports = (crowi) => {
       const userGroupName = crowi.xss.process(name);
       const userGroup = await UserGroup.createGroupByName(userGroupName);
 
-      return res.apiv3({ userGroup });
+      return res.apiv3({ userGroup }, 201);
     }
     catch (err) {
       const msg = 'Error occurred in creating a user group';

+ 1 - 1
src/server/routes/apiv3/users.js

@@ -319,7 +319,7 @@ module.exports = (crowi) => {
   router.post('/invite', loginRequiredStrictly, adminRequired, csrf, validator.inviteEmail, apiV3FormValidator, async(req, res) => {
     try {
       const invitedUserList = await User.createUsersByInvitation(req.body.shapedEmailList, req.body.sendEmail);
-      return res.apiv3({ invitedUserList });
+      return res.apiv3({ invitedUserList }, 201);
     }
     catch (err) {
       logger.error('Error', err);

+ 0 - 2
src/server/routes/index.js

@@ -137,14 +137,12 @@ module.exports = function(crowi, app) {
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
   app.get('/_api/users.list'          , accessTokenParser , loginRequired , user.api.list);
   app.get('/_api/pages.list'          , accessTokenParser , loginRequired , page.api.list);
-  app.post('/_api/pages.create'       , accessTokenParser , loginRequiredStrictly , csrf, page.api.create);
   app.post('/_api/pages.update'       , accessTokenParser , loginRequiredStrictly , csrf, page.api.update);
   app.get('/_api/pages.get'           , accessTokenParser , loginRequired , page.api.get);
   app.get('/_api/pages.exist'         , accessTokenParser , loginRequired , page.api.exist);
   app.get('/_api/pages.updatePost'    , accessTokenParser, loginRequired, page.api.getUpdatePost);
   app.get('/_api/pages.getPageTag'    , accessTokenParser , loginRequired , page.api.getPageTag);
   // allow posting to guests because the client doesn't know whether the user logged in
-  app.post('/_api/pages.rename'       , accessTokenParser , loginRequiredStrictly , csrf, page.api.rename);
   app.post('/_api/pages.remove'       , loginRequiredStrictly , csrf, page.api.remove); // (Avoid from API Token)
   app.post('/_api/pages.revertRemove' , loginRequiredStrictly , csrf, page.api.revertRemove); // (Avoid from API Token)
   app.post('/_api/pages.unlink'       , loginRequiredStrictly , csrf, page.api.unlink); // (Avoid from API Token)

+ 1 - 51
src/server/routes/page.js

@@ -711,57 +711,7 @@ module.exports = function(crowi, app) {
     }
   };
 
-  /**
-   * @swagger
-   *
-   *    /pages.create:
-   *      post:
-   *        tags: [Pages, CrowiCompatibles]
-   *        operationId: createPage
-   *        summary: /pages.create
-   *        description: Create page
-   *        requestBody:
-   *          content:
-   *            application/json:
-   *              schema:
-   *                properties:
-   *                  body:
-   *                    $ref: '#/components/schemas/Revision/properties/body'
-   *                  path:
-   *                    $ref: '#/components/schemas/Page/properties/path'
-   *                  grant:
-   *                    $ref: '#/components/schemas/Page/properties/grant'
-   *                required:
-   *                  - body
-   *                  - path
-   *        responses:
-   *          200:
-   *            description: Succeeded to create page.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
-   *                    page:
-   *                      $ref: '#/components/schemas/Page'
-   *                    revision:
-   *                      $ref: '#/components/schemas/Revision'
-   *          403:
-   *            $ref: '#/components/responses/403'
-   *          500:
-   *            $ref: '#/components/responses/500'
-   */
-  /**
-   * @api {post} /pages.create Create new page
-   * @apiName CreatePage
-   * @apiGroup Page
-   *
-   * @apiParam {String} body
-   * @apiParam {String} path
-   * @apiParam {String} grant
-   * @apiParam {Array} pageTags
-   */
+  // TODO If everything that depends on this route, delete it too
   api.create = async function(req, res) {
     const body = req.body.body || null;
     let pagePath = req.body.path || null;

+ 56 - 0
src/server/service/page.js

@@ -1,3 +1,7 @@
+const mongoose = require('mongoose');
+const escapeStringRegexp = require('escape-string-regexp');
+const { serializePageSecurely } = require('../models/serializers/page-serializer');
+
 class PageService {
 
   constructor(crowi) {
@@ -38,6 +42,58 @@ class PageService {
     return Promise.all(promises);
   }
 
+  async duplicate(page, newPagePath, user) {
+    const Page = this.crowi.model('Page');
+    const PageTagRelation = mongoose.model('PageTagRelation');
+    // populate
+    await page.populate({ path: 'revision', model: 'Revision', select: 'body' }).execPopulate();
+
+    // create option
+    const options = { page };
+    options.grant = page.grant;
+    options.grantUserGroupId = page.grantedGroup;
+    options.grantedUsers = page.grantedUsers;
+
+    const createdPage = await Page.create(
+      newPagePath, page.revision.body, user, options,
+    );
+
+    // take over tags
+    const originTags = await page.findRelatedTagsById();
+    let savedTags = [];
+    if (originTags != null) {
+      await PageTagRelation.updatePageTags(createdPage.id, originTags);
+      savedTags = await PageTagRelation.listTagNamesByPage(createdPage.id);
+    }
+
+    const result = serializePageSecurely(createdPage);
+    result.tags = savedTags;
+
+    return result;
+  }
+
+  async duplicateRecursively(page, newPagePath, user) {
+    const Page = this.crowi.model('Page');
+    const newPagePathPrefix = newPagePath;
+    const pathRegExp = new RegExp(`^${escapeStringRegexp(page.path)}`, 'i');
+
+    const pages = await Page.findManageableListWithDescendants(page, user);
+
+    const promise = pages.map(async(page) => {
+      const newPagePath = page.path.replace(pathRegExp, newPagePathPrefix);
+      return this.duplicate(page, newPagePath, user);
+    });
+
+    const newPath = page.path.replace(pathRegExp, newPagePathPrefix);
+
+    await Promise.allSettled(promise);
+
+    const newParentpage = await Page.findByPath(newPath);
+
+    // TODO GW-4634 use stream
+    return newParentpage;
+  }
+
 
 }
 

+ 50 - 0
src/server/service/user-notification/index.js

@@ -0,0 +1,50 @@
+const toArrayFromCsv = require('@commons/util/to-array-from-csv');
+
+/**
+ * service class of UserNotification
+ */
+class UserNotificationService {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+
+    this.Page = this.crowi.model('Page');
+  }
+
+  /**
+   * fire user notification
+   *
+   * @memberof UserNotificationService
+   *
+   * @param {Page} page
+   * @param {User} user
+   * @param {string} slackChannelsStr comma separated string. e.g. 'general,channel1,channel2'
+   * @param {boolean} updateOrCreate
+   * @param {string} previousRevision
+   */
+  async fire(page, user, slackChannelsStr, updateOrCreate, previousRevision) {
+    const { slackNotificationService, slack } = this.crowi;
+
+    await page.updateSlackChannel(slackChannelsStr);
+
+    if (!slackNotificationService.hasSlackConfig()) {
+      throw new Error('slackNotificationService has not been set up');
+    }
+
+    // "dev,slacktest" => [dev,slacktest]
+    const slackChannels = toArrayFromCsv(slackChannelsStr);
+
+    const promises = slackChannels.map(async(chan) => {
+      const res = await slack.postPage(page, user, chan, updateOrCreate, previousRevision);
+      if (res.status !== 'ok') {
+        throw new Error(`fail to send slack notification to #${chan} channel`);
+      }
+      return res;
+    });
+
+    return Promise.allSettled(promises);
+  }
+
+}
+
+module.exports = UserNotificationService;

+ 30 - 0
src/test/libs/to-array-from-csv.test.js

@@ -0,0 +1,30 @@
+const toArrayFromCsv = require('@commons/util/to-array-from-csv');
+
+describe('To array from csv', () => {
+
+  test('case 1', () => {
+    const result = toArrayFromCsv('dev,general');
+    expect(result).toStrictEqual(['dev', 'general']);
+  });
+
+  test('case 2', () => {
+    const result = toArrayFromCsv('dev');
+    expect(result).toStrictEqual(['dev']);
+  });
+
+  test('case 3', () => {
+    const result = toArrayFromCsv('');
+    expect(result).toStrictEqual([]);
+  });
+
+  test('case 4', () => {
+    const result = toArrayFromCsv('dev, general');
+    expect(result).toStrictEqual(['dev', 'general']);
+  });
+
+  test('case 5', () => {
+    const result = toArrayFromCsv(',dev,general');
+    expect(result).toStrictEqual(['dev', 'general']);
+  });
+
+});

+ 37 - 1
src/test/util/path-utils.test.js

@@ -1,4 +1,4 @@
-const { isTopPage } = require('../../lib/util/path-utils');
+const { isTopPage, convertToNewAffiliationPath } = require('../../lib/util/path-utils');
 
 
 describe('TopPage Path test', () => {
@@ -19,3 +19,39 @@ describe('TopPage Path test', () => {
     expect(result).toBe(false);
   });
 });
+
+
+describe('convertToNewAffiliationPath test', () => {
+  test('Child path is not converted normally', () => {
+    const result = convertToNewAffiliationPath('parent/', 'parent2/', 'parent/child');
+    expect(result).toBe('parent2/child');
+  });
+
+  test('Parent path is not converted normally', () => {
+    const result = convertToNewAffiliationPath('parent/', 'parent3/', 'parent/child');
+    expect(result === 'parent/child').toBe(false);
+  });
+
+  test('Parent and Child path names are switched unexpectedly', () => {
+    const result = convertToNewAffiliationPath('parent/', 'parent4/', 'parent/child');
+    expect(result === 'child/parent4').toBe(false);
+  });
+
+  test('Child path is null', () => {
+    expect(() => {
+      convertToNewAffiliationPath('parent/', 'parent5/', null);
+    }).toThrow();
+  });
+
+  test('Old parent path is null', () => {
+    expect(() => {
+      convertToNewAffiliationPath(null, 'parent5/', 'child');
+    }).toThrow();
+  });
+
+  test('New parent path is null', () => {
+    expect(() => {
+      convertToNewAffiliationPath('parent/', null, 'child');
+    }).toThrow();
+  });
+});