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

Merge branch 'imprv/can-update-SES-setting-in-the-mail-setting-form' into imprv/create-migration-copy-awa-setting-to-ses

# Conflicts:
#	src/client/js/components/Admin/App/SesSetting.jsx
itizawa 5 лет назад
Родитель
Сommit
75a35f2f3c
29 измененных файлов с 279 добавлено и 992 удалено
  1. 0 5
      package.json
  2. 1 8
      resource/locales/en_US/translation.json
  3. 1 8
      resource/locales/ja_JP/translation.json
  4. 4 11
      resource/locales/zh_CN/translation.json
  5. 0 60
      src/client/js/components/ComparePathsTable.jsx
  6. 8 7
      src/client/js/components/EmptyTrashModal.jsx
  7. 2 0
      src/client/js/components/Page/PageManagement.jsx
  8. 8 6
      src/client/js/components/PageDeleteModal.jsx
  9. 22 102
      src/client/js/components/PageDuplicateModal.jsx
  10. 0 23
      src/client/js/components/PageManagement/ApiErrorMessageList.jsx
  11. 13 56
      src/client/js/components/PageRenameModal.jsx
  12. 8 5
      src/client/js/components/PutbackPageModal.jsx
  13. 16 13
      src/client/js/services/PageContainer.js
  14. 0 6
      src/client/styles/scss/_create-page.scss
  15. 0 9
      src/client/styles/scss/_page-duplicate-modal.scss
  16. 0 1
      src/client/styles/scss/style-app.scss
  17. 0 16
      src/lib/util/path-utils.js
  18. 0 15
      src/lib/util/to-array-from-csv.js
  19. 0 15
      src/server/crowi/index.js
  20. 2 2
      src/server/routes/apiv3/notification-setting.js
  21. 15 538
      src/server/routes/apiv3/pages.js
  22. 2 2
      src/server/routes/apiv3/response.js
  23. 1 1
      src/server/routes/apiv3/share-links.js
  24. 1 1
      src/server/routes/apiv3/user-group.js
  25. 1 1
      src/server/routes/apiv3/users.js
  26. 2 0
      src/server/routes/index.js
  27. 172 1
      src/server/routes/page.js
  28. 0 50
      src/server/service/user-notification/index.js
  29. 0 30
      src/test/libs/to-array-from-csv.test.js

+ 0 - 5
package.json

@@ -266,11 +266,6 @@
     "@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",

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

@@ -134,8 +134,6 @@
   "Disassociate": "Disassociate",
   "Recent Created": "Recent Created",
   "Recent Changes": "Recent Changes",
-  "original_path":"Original path",
-  "new_path":"New path",
   "personal_dropdown": {
     "home": "Home",
     "settings": "Settings",
@@ -297,7 +295,6 @@
     "label": {
       "Move/Rename page": "Move/Rename page",
       "New page name": "New page name",
-      "Fail to get subordinated pages": "Fail to get subordinated pages",
       "Current page name": "Current page name",
       "Recursively": "Recursively",
       "Do not update metadata": "Do not update metadata",
@@ -328,11 +325,7 @@
     "label": {
       "Duplicate page": "Duplicate page",
       "New page name": "New page name",
-      "Fail to get subordinated pages": "Fail to get subordinated pages",
-      "Current page name": "Current page name",
-      "Duplicate with child": "Duplicate with child",
-      "Duplicate without exist path": "Duplicate without exist path",
-      "Same page already exists": "Same page already exists"
+      "Current page name": "Current page name"
     }
   },
   "modal_putback": {

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

@@ -137,8 +137,6 @@
   "Sidebar mode on Editor": "サイドバーモード(編集時)",
   "Recent Created": "最新の作成",
   "Recent Changes": "最新の変更",
-  "original_path":"元のパス",
-  "new_path":"新しいパス",
   "personal_dropdown": {
     "home": "ホーム",
     "settings": "設定",
@@ -299,7 +297,6 @@
     "label": {
       "Move/Rename page": "ページを移動/名前変更する",
       "New page name": "移動先のページ名",
-      "Fail to get subordinated pages": "配下ページの取得に失敗しました",
       "Current page name": "現在のページ名",
       "Recursively": "再帰的に移動/名前変更",
       "Do not update metadata": "メタデータを更新しない",
@@ -330,11 +327,7 @@
     "label": {
       "Duplicate page": "ページを複製する",
       "New page name": "複製後のページ名",
-      "Fail to get subordinated pages": "配下ページの取得に失敗しました",
-      "Current page name": "現在のページ名",
-      "Duplicate with child": "配下のページも一緒に複製する",
-      "Duplicate without exist path": "存在するパス以外を複製する",
-      "Same page already exists": "同じページがすでに存在します"
+      "Current page name": "現在のページ名"
     }
   },
   "modal_putback": {

+ 4 - 11
resource/locales/zh_CN/translation.json

@@ -136,9 +136,7 @@
 	"Sign out": "退出",
 	"Disassociate": "解除关联",
 	"Recent Created": "最新创建",
-  "Recent Changes": "最新修改",
-  "original_path":"Original path",
-  "new_path":"New path",
+	"Recent Changes": "最新修改",
 	"form_validation": {
 		"error_message": "有些值不正确",
 		"required": "%s 是必需的",
@@ -271,8 +269,7 @@
 	"modal_rename": {
 		"label": {
 			"Move/Rename page": "页面 移动/重命名",
-      "New page name": "新建页面名称",
-      "Fail to get subordinated pages": "Fail to get subordinated pages",
+			"New page name": "新建页面名称",
 			"Current page name": "当前页面名称",
 			"Recursively": "递归地",
 			"Do not update metadata": "不更新元数据",
@@ -302,12 +299,8 @@
 	"modal_duplicate": {
 		"label": {
 			"Duplicate page": "Duplicate page",
-      "New page name": "New page name",
-      "Fail to get subordinated pages": "Fail to get subordinated pages",
-			"Current page name": "Current page name",
-      "Duplicate with child": "Duplicate with child",
-      "Duplicate without exist path": "Duplicate without exist path",
-      "Same page already exists": "Same page already exists"
+			"New page name": "New page name",
+			"Current page name": "Current page name"
 		}
 	},
 	"modal_putback": {

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

@@ -1,60 +0,0 @@
-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-page-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);

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

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

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

@@ -19,6 +19,7 @@ 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);
@@ -35,6 +36,7 @@ const PageManagement = (props) => {
   function openPageDuplicateModalHandler() {
     setIsPageDuplicateModalShown(true);
   }
+
   function closePageDuplicateModalHandler() {
     setIsPageDuplicateModalShown(false);
   }

+ 8 - 6
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 ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
+import ApiErrorMessage from './PageManagement/ApiErrorMessage';
 
 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 [errs, setErrs] = useState(null);
+  const [errorCode, setErrorCode] = useState(null);
+  const [errorMessage, setErrorMessage] = useState(null);
 
   function changeIsDeleteRecursivelyHandler() {
     setIsDeleteRecursively(!isDeleteRecursively);
@@ -47,7 +47,8 @@ const PageDeleteModal = (props) => {
   }
 
   async function deletePage() {
-    setErrs(null);
+    setErrorCode(null);
+    setErrorMessage(null);
 
     try {
       const response = await pageContainer.deletePage(isDeleteRecursively, isDeleteCompletely);
@@ -55,7 +56,8 @@ const PageDeleteModal = (props) => {
       window.location.href = encodeURI(trashPagePath);
     }
     catch (err) {
-      setErrs(err);
+      setErrorCode(err.code);
+      setErrorMessage(err.message);
     }
   }
 
@@ -122,7 +124,7 @@ const PageDeleteModal = (props) => {
         {!isDeleteCompletelyModal && renderDeleteCompletelyForm()}
       </ModalBody>
       <ModalFooter>
-        <ApiErrorMessageList errs={errs} />
+        <ApiErrorMessage errorCode={errorCode} errorMessage={errorMessage} />
         <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}`) }

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

@@ -1,4 +1,4 @@
-import React, { useState, useEffect, useCallback } from 'react';
+import React, { useState } from 'react';
 import PropTypes from 'prop-types';
 
 import {
@@ -6,16 +6,13 @@ import {
 } from 'reactstrap';
 
 import { withTranslation } from 'react-i18next';
+
 import { withUnstatedContainers } from './UnstatedUtils';
-import { toastError } from '../util/apiNotification';
 
 import AppContainer from '../services/AppContainer';
 import PageContainer from '../services/PageContainer';
 import PagePathAutoComplete from './PagePathAutoComplete';
-import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
-import ComparePathsTable from './ComparePathsTable';
-
-const LIMIT_FOR_LIST = 10;
+import ApiErrorMessage from './PageManagement/ApiErrorMessage';
 
 const PageDuplicateModal = (props) => {
   const { t, appContainer, pageContainer } = props;
@@ -26,34 +23,16 @@ const PageDuplicateModal = (props) => {
   const { crowi } = appContainer.config;
 
   const [pageNameInput, setPageNameInput] = useState(path);
-
-  const [errs, setErrs] = useState(null);
-
-  const [subordinatedPages, setSubordinatedPages] = useState([]);
-  const [isDuplicateRecursively, setIsDuplicateRecursively] = useState(false);
-  const [isDuplicateRecursivelyWithoutExistPath, setIsDuplicateRecursivelyWithoutExistPath] = useState(false);
-  const [isDuplicateRecursivelyExist] = useState(false);
-
-  function getSubordinatedDuplicateList(value) {
-
-    // ToDo: get the duplicated list from sever
-    // below is psuedo code
-    // let duplicatedList = get.apiv3......
-    // duplicatedList = duplicatedList.map((value) =>
-    // <li className="duplicate-exist" key={value}> {value}: { t('modal_duplicate.label.Same page already exists') } </li>; )
-    // setIsDuplicateExist(duplicatedList);
-
-    // ToDo: for now we use dummy path
-    return [];
-  }
+  const [errorCode, setErrorCode] = useState(null);
+  const [errorMessage, setErrorMessage] = useState(null);
 
   /**
    * change pageNameInput for PagePathAutoComplete
    * @param {string} value
    */
   function ppacInputChangeHandler(value) {
-    getSubordinatedDuplicateList(value);
-    setErrs(null);
+    setErrorCode(null);
+    setErrorMessage(null);
     setPageNameInput(value);
   }
 
@@ -62,46 +41,22 @@ const PageDuplicateModal = (props) => {
    * @param {string} value
    */
   function inputChangeHandler(value) {
-    getSubordinatedDuplicateList(value);
-    setErrs(null);
+    setErrorCode(null);
+    setErrorMessage(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 {
-      await appContainer.apiv3Post('/pages/duplicate', { pageId, pageNameInput, isDuplicateRecursively });
-      window.location.href = encodeURI(`${pageNameInput}?duplicated=${path}`);
+      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}`);
     }
     catch (err) {
-      setErrs(err);
+      setErrorCode(err.code);
+      setErrorMessage(err.message);
     }
   }
 
@@ -115,8 +70,9 @@ 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 />
@@ -145,49 +101,13 @@ 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.Duplicate with child') }
-          </label>
-        </div>
-        {isDuplicateRecursively && <ComparePathsTable subordinatedPages={subordinatedPages} newPagePath={pageNameInput} />}
-
-        {isDuplicateRecursively && (
-          <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>
-        )}
       </ModalBody>
       <ModalFooter>
-        <ApiErrorMessageList errs={errs} targetPath={pageNameInput} />
-        <button
-          type="button"
-          className="btn btn-primary"
-          onClick={duplicate}
-          disabled={(isDuplicateRecursively && isDuplicateRecursivelyExist && !isDuplicateRecursivelyWithoutExistPath) || (path === pageNameInput)}
-        >
-          { t('modal_duplicate.label.Duplicate page') }
-        </button>
+        <ApiErrorMessage errorCode={errorCode} errorMessage={errorMessage} targetPath={pageNameInput} />
+        <button type="button" className="btn btn-primary" onClick={duplicate}>Duplicate page</button>
       </ModalFooter>
     </Modal>
+
   );
 };
 

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

@@ -1,23 +0,0 @@
-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;

+ 13 - 56
src/client/js/components/PageRenameModal.jsx

@@ -1,4 +1,4 @@
-import React, { useState, useEffect, useCallback } from 'react';
+import React, { useState } from 'react';
 import PropTypes from 'prop-types';
 
 import {
@@ -8,12 +8,10 @@ import {
 import { withTranslation } from 'react-i18next';
 
 import { withUnstatedContainers } from './UnstatedUtils';
-import { toastError } from '../util/apiNotification';
 
 import AppContainer from '../services/AppContainer';
 import PageContainer from '../services/PageContainer';
-import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
-import ComparePathsTable from './ComparePathsTable';
+import ApiErrorMessage from './PageManagement/ApiErrorMessage';
 
 const PageRenameModal = (props) => {
   const {
@@ -25,25 +23,17 @@ 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 [isRenameRecursively, SetIsRenameRecursively] = useState(true);
   const [isRenameRedirect, SetIsRenameRedirect] = useState(false);
   const [isRenameMetadata, SetIsRenameMetadata] = useState(false);
-  const [subordinatedError] = useState(null);
-  const [isDuplicateRecursivelyWithoutExistPath, setIsDuplicateRecursivelyWithoutExistPath] = useState(true);
-
 
   function changeIsRenameRecursivelyHandler() {
     SetIsRenameRecursively(!isRenameRecursively);
   }
 
-  function changeIsDuplicateRecursivelyWithoutExistPathHandler() {
-    setIsDuplicateRecursivelyWithoutExistPath(!isDuplicateRecursivelyWithoutExistPath);
-  }
-
   function changeIsRenameRedirectHandler() {
     SetIsRenameRedirect(!isRenameRedirect);
   }
@@ -52,37 +42,21 @@ 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_duplicate.label.Fail to get subordinated pages'));
-    }
-  }, [appContainer, path, t]);
-
-  useEffect(() => {
-    if (props.isOpen) {
-      updateSubordinatedList();
-    }
-  }, [props.isOpen, updateSubordinatedList]);
-
   /**
    * change pageNameInput
    * @param {string} value
    */
   function inputChangeHandler(value) {
-    setErrs(null);
+    setErrorCode(null);
+    setErrorMessage(null);
     setPageNameInput(value);
   }
 
   async function rename() {
-    setErrs(null);
-
     try {
+      setErrorCode(null);
+      setErrorMessage(null);
+
       const response = await pageContainer.rename(
         pageNameInput,
         isRenameRecursively,
@@ -90,7 +64,7 @@ const PageRenameModal = (props) => {
         isRenameMetadata,
       );
 
-      const { page } = response.data;
+      const { page } = response;
       const url = new URL(page.path, 'https://dummy');
       url.searchParams.append('renamedFrom', path);
       if (isRenameRedirect) {
@@ -100,7 +74,8 @@ const PageRenameModal = (props) => {
       window.location.href = `${url.pathname}${url.search}`;
     }
     catch (err) {
-      setErrs(err);
+      setErrorCode(err.code);
+      setErrorMessage(err.message);
     }
   }
 
@@ -144,23 +119,6 @@ const PageRenameModal = (props) => {
             { t('modal_rename.label.Recursively') }
             <p className="form-text text-muted mt-0">{ t('modal_rename.help.recursive') }</p>
           </label>
-          <div
-            className="custom-control custom-checkbox custom-checkbox-warning"
-            style={{ display: isRenameRecursively ? '' : 'none' }}
-          >
-            <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>
-          {isRenameRecursively && <ComparePathsTable subordinatedPages={subordinatedPages} newPagePath={pageNameInput} />}
         </div>
 
         <div className="custom-control custom-checkbox custom-checkbox-success">
@@ -192,10 +150,9 @@ const PageRenameModal = (props) => {
             <p className="form-text text-muted mt-0">{ t('modal_rename.help.metadata') }</p>
           </label>
         </div>
-        <div> {subordinatedError} </div>
       </ModalBody>
       <ModalFooter>
-        <ApiErrorMessageList errs={errs} targetPath={pageNameInput} />
+        <ApiErrorMessage errorCode={errorCode} errorMessage={errorMessage} targetPath={pageNameInput} />
         <button type="button" className="btn btn-primary" onClick={rename}>Rename</button>
       </ModalFooter>
     </Modal>

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

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

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

@@ -325,10 +325,11 @@ export default class PageContainer extends Container {
       body: markdown,
     });
 
-    const res = await this.appContainer.apiv3Post('/pages/', params);
-    const { page, tags } = res.data;
-
-    return { page, tags };
+    const res = await this.appContainer.apiPost('/pages.create', params);
+    if (!res.ok) {
+      throw new Error(res.error);
+    }
+    return { page: res.page, tags: res.tags };
   }
 
   async updatePage(pageId, revisionId, markdown, tmpParams) {
@@ -379,17 +380,19 @@ export default class PageContainer extends Container {
     });
   }
 
-  rename(newPagePath, isRecursively, isRenameRedirect, isRemainMetadata) {
+  rename(pageNameInput, isRenameRecursively, isRenameRedirect, isRenameMetadata) {
     const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
-    const { pageId, revisionId } = this.state;
+    const isRecursively = isRenameRecursively ? true : null;
+    const isRedirect = isRenameRedirect ? true : null;
+    const isRemain = isRenameMetadata ? true : null;
 
-    return this.appContainer.apiv3Put('/pages/rename', {
-      revisionId,
-      pageId,
-      isRecursively,
-      isRenameRedirect,
-      isRemainMetadata,
-      newPagePath,
+    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,
       socketClientId: socketIoContainer.getSocketClientId(),
     });
   }

+ 0 - 6
src/client/styles/scss/_create-page.scss

@@ -12,10 +12,4 @@
   .create-page-under-tree-label code {
     font-family: $font-family-monospace-not-strictly;
   }
-
-  .grw-compare-page-table {
-    tbody {
-      max-height: 200px;
-    }
-  }
 }

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

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

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

@@ -46,7 +46,6 @@
 @import 'navbar';
 @import 'navbar_kibela';
 @import 'on-edit';
-@import 'page-duplicate-modal';
 @import 'page_list';
 @import 'page-path';
 @import 'page';

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

@@ -1,5 +1,3 @@
-const escapeStringRegexp = require('escape-string-regexp');
-
 /**
  * Whether path is the top page
  * @param {string} path
@@ -49,23 +47,9 @@ 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) => {
-  const pathRegExp = new RegExp(`^${escapeStringRegexp(oldPath)}`, 'i');
-  return childPath.replace(pathRegExp, newPath);
-};
-
 module.exports = {
   isTopPage,
   isTrashPage,
   isUserPage,
   userPageRoot,
-  convertToNewAffiliationPath,
 };

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

@@ -1,15 +0,0 @@
-// 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;

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

@@ -41,7 +41,6 @@ function Crowi(rootdir) {
   this.mailService = null;
   this.passportService = null;
   this.globalNotificationService = null;
-  this.userNotificationService = null;
   this.slackNotificationService = null;
   this.xssService = null;
   this.aclService = null;
@@ -316,10 +315,6 @@ Crowi.prototype.getGlobalNotificationService = function() {
   return this.globalNotificationService;
 };
 
-Crowi.prototype.getUserNotificationService = function() {
-  return this.userNotificationService;
-};
-
 Crowi.prototype.getRestQiitaAPIService = function() {
   return this.restQiitaAPIService;
 };
@@ -485,16 +480,6 @@ Crowi.prototype.setUpGlobalNotification = async function() {
   }
 };
 
-/**
- * setup UserNotificationService
- */
-Crowi.prototype.setUpGlobalNotification = async function() {
-  const UserNotificationService = require('../service/user-notification');
-  if (this.userNotificationService == null) {
-    this.userNotificationService = new UserNotificationService(this);
-  }
-};
-
 /**
  * setup SlackNotificationService
  */

+ 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 }, 201);
+      return res.apiv3({ responseParams });
     }
     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 }, 201);
+      return res.apiv3({ createdNotification });
     }
     catch (err) {
       const msg = 'Error occurred in updating global notification';

+ 15 - 538
src/server/routes/apiv3/pages.js

@@ -3,267 +3,21 @@ 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 escapeStringRegexp = require('escape-string-regexp');
 
-const { body } = require('express-validator/check');
-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 { pageService } = crowi;
-
-  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: pageService.serializeToObj(createdPage), tags: savedTags };
-
-    // update scopes for descendants
-    if (overwriteScopesOfDescendants) {
-      Page.applyScopesToDescendantsAsyncronously(createdPage, req.user);
-    }
-
-    // global notification
-    if (globalNotificationService != null) {
-      try {
-        await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, createdPage, req.user);
-      }
-      catch (err) {
-        logger.error('Create grobal notification failed', err);
-      }
-    }
-
-    // user notification
-    if (isSlackEnabled && userNotificationService != null) {
-      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);
-  });
 
   /**
    * @swagger
@@ -299,311 +53,34 @@ module.exports = (crowi) => {
       return res.apiv3(result);
     }
     catch (err) {
+      res.code = 'unknown';
       logger.error('Failed to get recent pages', err);
-      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: pageService.serializeToObj(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.apiv3Err(err, 500);
     }
-
-    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('/trash', req.user);
       return res.apiv3({ pages });
     }
     catch (err) {
-      return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
-    }
-  });
-
-  async function duplicatePage(page, newPagePath, user) {
-    // 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 createPageAction({
-      path: newPagePath, user, body: page.revision.body, options,
-    });
-
-    const originTags = await page.findRelatedTagsById();
-    const savedTags = await saveTagsAction({ page, createdPage, pageTags: originTags });
-
-    // global notification
-    if (globalNotificationService != null) {
-      try {
-        await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, createdPage, user);
-      }
-      catch (err) {
-        logger.error('Create grobal notification failed', err);
-      }
-    }
-
-    return { page: pageService.serializeToObj(createdPage), tags: savedTags };
-  }
-
-  async function duplicatePageRecursively(page, newPagePath, user) {
-    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 duplicatePage(page, newPagePath, user);
-    });
-
-    return Promise.allSettled(promise);
-  }
-
-
-  /**
-   * @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);
+      res.code = 'unknown';
+      logger.error('Failed to delete trash pages', err);
+      return res.apiv3Err(err, 500);
     }
-
-    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 result;
-
-    if (isRecursively) {
-      result = await duplicatePageRecursively(page, newPagePath, req.user);
-    }
-    else {
-      result = await duplicatePage(page, newPagePath, req.user);
-    }
-
-    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 = {}, status = 200) { // not arrow function
+  express.response.apiv3 = function(obj = {}) { // not arrow function
     // obj must be object
     if (typeof obj !== 'object' || obj instanceof Array) {
       throw new Error('invalid value supplied to res.apiv3');
     }
 
-    this.status(status).json({ data: obj });
+    this.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, 201);
+      return res.apiv3(postedShareLink);
     }
     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 }, 201);
+      return res.apiv3({ userGroup });
     }
     catch (err) {
       const msg = 'Error occurred in creating a user group';

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

@@ -245,7 +245,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 }, 201);
+      return res.apiv3({ invitedUserList });
     }
     catch (err) {
       logger.error('Error', err);

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

@@ -135,6 +135,7 @@ module.exports = function(crowi, app) {
   app.get('/_api/users.list'          , accessTokenParser , loginRequired , user.api.list);
   app.get('/_api/pages.list'          , accessTokenParser , loginRequired , page.api.list);
   app.get('/_api/pages.recentCreated' , accessTokenParser , loginRequired , page.api.recentCreated);
+  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);
@@ -142,6 +143,7 @@ module.exports = function(crowi, app) {
   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.seen'         , accessTokenParser , loginRequired , page.api.seen);
+  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)

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

@@ -671,7 +671,55 @@ module.exports = function(crowi, app) {
     }
   };
 
-  // TODO If everything that depends on this route, delete it too
+  /**
+   * @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'
+   *          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
+   */
   api.create = async function(req, res) {
     const body = req.body.body || null;
     let pagePath = req.body.path || null;
@@ -1291,6 +1339,129 @@ module.exports = function(crowi, app) {
     return res.json(ApiResponse.success(result));
   };
 
+  /**
+   * @swagger
+   *
+   *    /pages.rename:
+   *      post:
+   *        tags: [Pages, CrowiCompatibles]
+   *        operationId: renamePage
+   *        summary: /pages.rename
+   *        description: Rename page
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  page_id:
+   *                    $ref: '#/components/schemas/Page/properties/_id'
+   *                  path:
+   *                    $ref: '#/components/schemas/Page/properties/path'
+   *                  revision_id:
+   *                    $ref: '#/components/schemas/Revision/properties/_id'
+   *                  new_path:
+   *                    type: string
+   *                    description: new path
+   *                    example: /user/alice/new_test
+   *                  create_redirect:
+   *                    type: boolean
+   *                    description: whether redirect page
+   *                required:
+   *                  - page_id
+   *        responses:
+   *          200:
+   *            description: Succeeded to rename page.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    ok:
+   *                      $ref: '#/components/schemas/V1Response/properties/ok'
+   *                    page:
+   *                      $ref: '#/components/schemas/Page'
+   *          403:
+   *            $ref: '#/components/responses/403'
+   *          500:
+   *            $ref: '#/components/responses/500'
+   */
+  /**
+   * @api {post} /pages.rename Rename page
+   * @apiName RenamePage
+   * @apiGroup Page
+   *
+   * @apiParam {String} page_id Page Id.
+   * @apiParam {String} path
+   * @apiParam {String} revision_id
+   * @apiParam {String} new_path New path name.
+   * @apiParam {Bool} create_redirect
+   */
+  api.rename = async function(req, res) {
+    const pageId = req.body.page_id;
+    const previousRevision = req.body.revision_id || null;
+    let newPagePath = pathUtils.normalizePath(req.body.new_path);
+    const options = {
+      createRedirectPage: (req.body.create_redirect != null),
+      updateMetadata: (req.body.remain_metadata == null),
+      socketClientId: +req.body.socketClientId || undefined,
+    };
+    const isRecursively = (req.body.recursively != null);
+
+    if (!Page.isCreatableName(newPagePath)) {
+      return res.json(ApiResponse.error(`Could not use the path '${newPagePath})'`, 'invalid_path'));
+    }
+
+    // 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.json(ApiResponse.error(`'new_path=${newPagePath}' already exists`, 'already_exists'));
+    }
+
+    let page;
+
+    try {
+      page = await Page.findByIdAndViewer(pageId, req.user);
+
+      if (page == null) {
+        return res.json(ApiResponse.error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'));
+      }
+
+      if (!page.isUpdatable(previousRevision)) {
+        return res.json(ApiResponse.error('Someone could update this page, so couldn\'t delete.', 'outdated'));
+      }
+
+      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.json(ApiResponse.error('Failed to update page.', 'unknown'));
+    }
+
+    const result = {};
+    result.page = page; // TODO consider to use serializeToObj method -- 2018.08.06 Yuki Takei
+
+    res.json(ApiResponse.success(result));
+
+    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 page;
+  };
+
   /**
    * @swagger
    *

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

@@ -1,50 +0,0 @@
-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;

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

@@ -1,30 +0,0 @@
-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']);
-  });
-
-});