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

Merge branch 'master' into feat/93361-94042-pagetree-light-theme-refactoring

cao 3 лет назад
Родитель
Сommit
96fa2317b9

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

@@ -186,9 +186,7 @@
     "error_message": "Some values ​​are incorrect",
     "error_message": "Some values ​​are incorrect",
     "required": "%s is required",
     "required": "%s is required",
     "invalid_syntax": "The syntax of %s is invalid.",
     "invalid_syntax": "The syntax of %s is invalid.",
-    "title_required": "Title is required.",
-    "slashed_are_not_yet_supported": "Titles containing slashes are not yet supported"
-
+    "title_required": "Title is required."
   },
   },
   "not_found_page": {
   "not_found_page": {
     "Create Page": "Create Page",
     "Create Page": "Create Page",
@@ -655,6 +653,11 @@
       "convert_recursively_desc": "Convert pages under this path recursively.",
       "convert_recursively_desc": "Convert pages under this path recursively.",
       "button_label": "Convert"
       "button_label": "Convert"
     },
     },
+    "toaster": {
+      "page_migration_succeeded": "Conversion of selected page to v5 has been successfully completed.",
+      "page_migration_failed_with_paths": "Conversion of {{paths}} to v5 has been failed.",
+      "page_migration_failed": "Conversion of page to v5 has been failed."
+    },
     "by_path_modal": {
     "by_path_modal": {
       "title": "Convert to new v5 compatible format",
       "title": "Convert to new v5 compatible format",
       "description": "Enter a path and all pages under that path will be converted to v5 compatible format.",
       "description": "Enter a path and all pages under that path will be converted to v5 compatible format.",

+ 6 - 2
packages/app/resource/locales/ja_JP/translation.json

@@ -188,8 +188,7 @@
     "error_message": "いくつかの値が設定されていません",
     "error_message": "いくつかの値が設定されていません",
     "required": "%sに値を入力してください",
     "required": "%sに値を入力してください",
     "invalid_syntax": "%sの構文が不正です",
     "invalid_syntax": "%sの構文が不正です",
-    "title_required": "タイトルを入力してください",
-    "slashed_are_not_yet_supported": "スラッシュを含むタイトルにはまだ対応していません"
+    "title_required": "タイトルを入力してください"
   },
   },
   "not_found_page": {
   "not_found_page": {
     "Create Page": "ページを作成する",
     "Create Page": "ページを作成する",
@@ -654,6 +653,11 @@
       "convert_recursively_desc": "このページの配下のページを再起的に変換します",
       "convert_recursively_desc": "このページの配下のページを再起的に変換します",
       "button_label": "変換"
       "button_label": "変換"
     },
     },
+    "toaster": {
+      "page_migration_succeeded": "選択されたページの v5 互換形式への変換が正常に終了しました。",
+      "page_migration_failed_with_paths": "{{paths}} の v5 互換形式への変換中にエラーが発生しました。",
+      "page_migration_failed": "ページの v5 互換形式への変換中にエラーが発生しました。"
+    },
     "by_path_modal": {
     "by_path_modal": {
       "title": "新しい v5 互換形式への変換",
       "title": "新しい v5 互換形式への変換",
       "description": "パスを入力することで、そのパスの配下のページを全て v5 互換形式に変換します",
       "description": "パスを入力することで、そのパスの配下のページを全て v5 互換形式に変換します",

+ 6 - 2
packages/app/resource/locales/zh_CN/translation.json

@@ -186,8 +186,7 @@
 		"error_message": "有些值不正确",
 		"error_message": "有些值不正确",
 		"required": "%s 是必需的",
 		"required": "%s 是必需的",
 		"invalid_syntax": "%s的语法无效。",
 		"invalid_syntax": "%s的语法无效。",
-    "title_required": "标题是必需的。",
-    "slashed_are_not_yet_supported": "目前还不支持包含斜线的标题"
+    "title_required": "标题是必需的。"
   },
   },
   "not_found_page": {
   "not_found_page": {
     "Create Page": "创建页面",
     "Create Page": "创建页面",
@@ -941,6 +940,11 @@
       "convert_recursively_desc": "递归地转换该路径下的页面。",
       "convert_recursively_desc": "递归地转换该路径下的页面。",
       "button_label": "转换"
       "button_label": "转换"
     },
     },
+    "toaster": {
+      "page_migration_succeeded": "已成功将所选页面转换为 v5 兼容格式。",
+      "page_migration_failed_with_paths": "将 {{paths}} 转换为 v5 兼容格式时出错",
+      "page_migration_failed": "将页面转换为 v5 兼容格式时出错。"
+    },
     "by_path_modal": {
     "by_path_modal": {
       "title": "转换为新的v5兼容格式",
       "title": "转换为新的v5兼容格式",
       "description": "输入一个路径,该路径下的所有页面将被转换为v5兼容格式。",
       "description": "输入一个路径,该路径下的所有页面将被转换为v5兼容格式。",

+ 1 - 1
packages/app/src/components/Admin/Users/UserInviteModal.jsx

@@ -171,7 +171,7 @@ class UserInviteModal extends React.Component {
     return (
     return (
       <ul>
       <ul>
         {userList.map((user) => {
         {userList.map((user) => {
-          const copyText = `Email:${user.email} Password:${user.password} `;
+          const copyText = `Email:${user.email} Password:${user.password}`;
           return (
           return (
             <div className="my-1" key={user.email}>
             <div className="my-1" key={user.email}>
               <CopyToClipboard text={copyText} onCopy={this.showToaster}>
               <CopyToClipboard text={copyText} onCopy={this.showToaster}>

+ 8 - 22
packages/app/src/components/PageCreateModal.jsx

@@ -1,24 +1,22 @@
 import React, {
 import React, {
   useEffect, useState, useMemo, useCallback,
   useEffect, useState, useMemo, useCallback,
 } from 'react';
 } from 'react';
-import PropTypes from 'prop-types';
 
 
+import { pagePathUtils, pathUtils } from '@growi/core';
+import { format } from 'date-fns';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
 import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
 
 
-import { withTranslation } from 'react-i18next';
-import { format } from 'date-fns';
-
-import { pagePathUtils, pathUtils } from '@growi/core';
-
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
-import { withUnstatedContainers } from './UnstatedUtils';
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
-
 import { usePageCreateModal } from '~/stores/modal';
 import { usePageCreateModal } from '~/stores/modal';
 
 
 import PagePathAutoComplete from './PagePathAutoComplete';
 import PagePathAutoComplete from './PagePathAutoComplete';
+import { withUnstatedContainers } from './UnstatedUtils';
+
 
 
 const {
 const {
   userPageRoot, isCreatablePage, generateEditorPath, isUsersHomePage,
   userPageRoot, isCreatablePage, generateEditorPath, isUsersHomePage,
@@ -83,14 +81,6 @@ const PageCreateModal = (props) => {
     setTodayInput2(value);
     setTodayInput2(value);
   }
   }
 
 
-  /**
-   * change pageNameInput
-   * @param {string} value
-   */
-  function onChangePageNameInputHandler(value) {
-    setPageNameInput(value);
-  }
-
   /**
   /**
    * change template
    * change template
    * @param {string} value
    * @param {string} value
@@ -131,10 +121,6 @@ const PageCreateModal = (props) => {
     redirectToEditor(pageNameInput);
     redirectToEditor(pageNameInput);
   }
   }
 
 
-  function ppacInputChangeHandler(value) {
-    setPageNameInput(value);
-  }
-
   function ppacSubmitHandler(input) {
   function ppacSubmitHandler(input) {
     redirectToEditor(input);
     redirectToEditor(input);
   }
   }
@@ -212,7 +198,7 @@ const PageCreateModal = (props) => {
                     initializedPath={pageNameInput}
                     initializedPath={pageNameInput}
                     addTrailingSlash
                     addTrailingSlash
                     onSubmit={ppacSubmitHandler}
                     onSubmit={ppacSubmitHandler}
-                    onInputChange={ppacInputChangeHandler}
+                    onInputChange={value => setPageNameInput(value)}
                     autoFocus
                     autoFocus
                   />
                   />
                 )
                 )
@@ -223,7 +209,7 @@ const PageCreateModal = (props) => {
                       value={pageNameInput}
                       value={pageNameInput}
                       className="form-control flex-fill"
                       className="form-control flex-fill"
                       placeholder={t('Input page name')}
                       placeholder={t('Input page name')}
-                      onChange={e => onChangePageNameInputHandler(e.target.value)}
+                      onChange={e => setPageNameInput(e.target.value)}
                       required
                       required
                     />
                     />
                   </form>
                   </form>

+ 5 - 7
packages/app/src/components/PageDuplicateModal.tsx

@@ -2,22 +2,20 @@ import React, {
   useState, useEffect, useCallback, useMemo,
   useState, useEffect, useCallback, useMemo,
 } from 'react';
 } from 'react';
 
 
+import { useTranslation } from 'react-i18next';
 import {
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
-
-import { useTranslation } from 'react-i18next';
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
 
 
-import { apiv3Get, apiv3Post } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
-
-import { usePageDuplicateModal } from '~/stores/modal';
+import { apiv3Get, apiv3Post } from '~/client/util/apiv3-client';
 import { useIsSearchServiceReachable, useSiteUrl } from '~/stores/context';
 import { useIsSearchServiceReachable, useSiteUrl } from '~/stores/context';
+import { usePageDuplicateModal } from '~/stores/modal';
 
 
-import PagePathAutoComplete from './PagePathAutoComplete';
-import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 import DuplicatePathsTable from './DuplicatedPathsTable';
 import DuplicatePathsTable from './DuplicatedPathsTable';
+import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
+import PagePathAutoComplete from './PagePathAutoComplete';
 
 
 
 
 const PageDuplicateModal = (): JSX.Element => {
 const PageDuplicateModal = (): JSX.Element => {

+ 35 - 18
packages/app/src/components/PageRenameModal.tsx

@@ -2,25 +2,25 @@ import React, {
   useState, useEffect, useCallback, useMemo,
   useState, useEffect, useCallback, useMemo,
 } from 'react';
 } from 'react';
 
 
+
+import { pagePathUtils } from '@growi/core';
+import { useTranslation } from 'react-i18next';
 import {
 import {
   Collapse, Modal, ModalHeader, ModalBody, ModalFooter,
   Collapse, Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
-
-import { useTranslation } from 'react-i18next';
-
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
-import { pagePathUtils } from '@growi/core';
-import { usePageRenameModal } from '~/stores/modal';
-import { toastError } from '~/client/util/apiNotification';
 
 
+import { toastError } from '~/client/util/apiNotification';
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
-
-import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
-import DuplicatedPathsTable from './DuplicatedPathsTable';
-import { useSiteUrl } from '~/stores/context';
 import { isIPageInfoForEntity } from '~/interfaces/page';
 import { isIPageInfoForEntity } from '~/interfaces/page';
+import { useSiteUrl, useIsSearchServiceReachable } from '~/stores/context';
+import { usePageRenameModal } from '~/stores/modal';
 import { useSWRxPageInfo } from '~/stores/page';
 import { useSWRxPageInfo } from '~/stores/page';
 
 
+import DuplicatedPathsTable from './DuplicatedPathsTable';
+import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
+import PagePathAutoComplete from './PagePathAutoComplete';
+
 
 
 const isV5Compatible = (meta: unknown): boolean => {
 const isV5Compatible = (meta: unknown): boolean => {
   return isIPageInfoForEntity(meta) ? meta.isV5Compatible : true;
   return isIPageInfoForEntity(meta) ? meta.isV5Compatible : true;
@@ -33,6 +33,7 @@ const PageRenameModal = (): JSX.Element => {
   const { isUsersHomePage } = pagePathUtils;
   const { isUsersHomePage } = pagePathUtils;
   const { data: siteUrl } = useSiteUrl();
   const { data: siteUrl } = useSiteUrl();
   const { data: renameModalData, close: closeRenameModal } = usePageRenameModal();
   const { data: renameModalData, close: closeRenameModal } = usePageRenameModal();
+  const { data: isReachable } = useIsSearchServiceReachable();
 
 
   const isOpened = renameModalData?.isOpened ?? false;
   const isOpened = renameModalData?.isOpened ?? false;
   const page = renameModalData?.page;
   const page = renameModalData?.page;
@@ -154,6 +155,11 @@ const PageRenameModal = (): JSX.Element => {
   }, [pageNameInput, subordinatedPages, checkExistPathsDebounce, page, checkIsUsersHomePageDebounce]);
   }, [pageNameInput, subordinatedPages, checkExistPathsDebounce, page, checkIsUsersHomePageDebounce]);
 
 
 
 
+  function ppacInputChangeHandler(value) {
+    setErrs(null);
+    setPageNameInput(value);
+  }
+
   /**
   /**
    * change pageNameInput
    * change pageNameInput
    * @param {string} value
    * @param {string} value
@@ -219,14 +225,25 @@ const PageRenameModal = (): JSX.Element => {
               <span className="input-group-text">{siteUrl}</span>
               <span className="input-group-text">{siteUrl}</span>
             </div>
             </div>
             <form className="flex-fill" onSubmit={(e) => { e.preventDefault(); rename() }}>
             <form className="flex-fill" onSubmit={(e) => { e.preventDefault(); rename() }}>
-              <input
-                type="text"
-                value={pageNameInput}
-                className="form-control"
-                onChange={e => inputChangeHandler(e.target.value)}
-                required
-                autoFocus
-              />
+              {isReachable
+                ? (
+                  <PagePathAutoComplete
+                    initializedPath={path}
+                    onSubmit={rename}
+                    onInputChange={ppacInputChangeHandler}
+                    autoFocus
+                  />
+                )
+                : (
+                  <input
+                    type="text"
+                    value={pageNameInput}
+                    className="form-control"
+                    onChange={e => inputChangeHandler(e.target.value)}
+                    required
+                    autoFocus
+                  />
+                )}
             </form>
             </form>
           </div>
           </div>
         </div>
         </div>

+ 40 - 15
packages/app/src/components/PrivateLegacyPages.tsx

@@ -1,34 +1,35 @@
 import React, {
 import React, {
-  useCallback, useMemo, useRef, useState,
+  useCallback, useMemo, useRef, useState, useEffect,
 } from 'react';
 } from 'react';
-import { useTranslation } from 'react-i18next';
 
 
+import { useTranslation } from 'react-i18next';
 import {
 import {
   UncontrolledButtonDropdown, DropdownToggle, DropdownMenu, DropdownItem, Modal, ModalHeader, ModalBody, ModalFooter,
   UncontrolledButtonDropdown, DropdownToggle, DropdownMenu, DropdownItem, Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-import { IFormattedSearchResult } from '~/interfaces/search';
-import AppContainer from '~/client/services/AppContainer';
 import { ISelectableAll, ISelectableAndIndeterminatable } from '~/client/interfaces/selectable-all';
 import { ISelectableAll, ISelectableAndIndeterminatable } from '~/client/interfaces/selectable-all';
+import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import {
-  useSWRxSearch,
-} from '~/stores/search';
+import { apiv3Post } from '~/client/util/apiv3-client';
+import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
+import { V5MigrationStatus } from '~/interfaces/page-listing-results';
+import { IFormattedSearchResult } from '~/interfaces/search';
+import { PageMigrationErrorData, SocketEventName } from '~/interfaces/websocket';
 import {
 import {
   ILegacyPrivatePage, usePrivateLegacyPagesMigrationModal,
   ILegacyPrivatePage, usePrivateLegacyPagesMigrationModal,
 } from '~/stores/modal';
 } from '~/stores/modal';
+import { useSWRxV5MigrationStatus } from '~/stores/page-listing';
+import {
+  useSWRxSearch,
+} from '~/stores/search';
+import { useGlobalSocket } from '~/stores/websocket';
 
 
-import PaginationWrapper from './PaginationWrapper';
-import { OperateAllControl } from './SearchPage/OperateAllControl';
-
-import { IReturnSelectedPageIds, SearchPageBase, usePageDeleteModalForBulkDeletion } from './SearchPage2/SearchPageBase';
 import { MenuItemType } from './Common/Dropdown/PageItemControl';
 import { MenuItemType } from './Common/Dropdown/PageItemControl';
+import PaginationWrapper from './PaginationWrapper';
 import { PrivateLegacyPagesMigrationModal } from './PrivateLegacyPagesMigrationModal';
 import { PrivateLegacyPagesMigrationModal } from './PrivateLegacyPagesMigrationModal';
+import { OperateAllControl } from './SearchPage/OperateAllControl';
 import SearchControl from './SearchPage/SearchControl';
 import SearchControl from './SearchPage/SearchControl';
-import { useSWRxV5MigrationStatus } from '~/stores/page-listing';
-import { V5MigrationStatus } from '~/interfaces/page-listing-results';
-import { apiv3Post } from '~/client/util/apiv3-client';
-import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
+import { IReturnSelectedPageIds, SearchPageBase, usePageDeleteModalForBulkDeletion } from './SearchPage2/SearchPageBase';
 
 
 
 
 // TODO: replace with "customize:showPageLimitationS"
 // TODO: replace with "customize:showPageLimitationS"
@@ -201,6 +202,30 @@ const PrivateLegacyPages = (props: Props): JSX.Element => {
   }, []);
   }, []);
 
 
   const { open: openModal, close: closeModal } = usePrivateLegacyPagesMigrationModal();
   const { open: openModal, close: closeModal } = usePrivateLegacyPagesMigrationModal();
+  const { data: socket } = useGlobalSocket();
+
+  useEffect(() => {
+    socket?.on(SocketEventName.PageMigrationSuccess, () => {
+      toastSuccess(t('private_legacy_pages.toaster.page_migration_succeeded'));
+    });
+
+    socket?.on(SocketEventName.PageMigrationError, (data?: PageMigrationErrorData) => {
+      if (data == null || data.paths.length === 0) {
+        toastError(t('private_legacy_pages.toaster.page_migration_failed'));
+      }
+      else {
+        const errorPaths = data.paths.length > 3
+          ? `${data.paths.slice(0, 3).join(', ')}...`
+          : data.paths.join(', ');
+        toastError(t('private_legacy_pages.toaster.page_migration_failed_with_paths', { paths: errorPaths }));
+      }
+    });
+
+    return () => {
+      socket?.off(SocketEventName.PageMigrationSuccess);
+      socket?.off(SocketEventName.PageMigrationError);
+    };
+  }, [socket]);
 
 
   const selectAllCheckboxChangedHandler = useCallback((isChecked: boolean) => {
   const selectAllCheckboxChangedHandler = useCallback((isChecked: boolean) => {
     const instance = searchPageBaseRef.current;
     const instance = searchPageBaseRef.current;

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

@@ -368,13 +368,6 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       };
       };
     }
     }
 
 
-    if (title.includes('/')) {
-      return {
-        type: AlertType.WARNING,
-        message: t('form_validation.slashed_are_not_yet_supported'),
-      };
-    }
-
     return null;
     return null;
   };
   };
 
 

+ 6 - 0
packages/app/src/interfaces/websocket.ts

@@ -7,6 +7,10 @@ export const SocketEventName = {
   PMMigrating: 'PublicMigrationMigrating',
   PMMigrating: 'PublicMigrationMigrating',
   PMErrorCount: 'PublicMigrationErrorCount',
   PMErrorCount: 'PublicMigrationErrorCount',
   PMEnded: 'PublicMigrationEnded',
   PMEnded: 'PublicMigrationEnded',
+
+  // Page migration
+  PageMigrationSuccess: 'PageMigrationSuccess',
+  PageMigrationError: 'PageMigrationError',
 } as const;
 } as const;
 export type SocketEventName = typeof SocketEventName[keyof typeof SocketEventName];
 export type SocketEventName = typeof SocketEventName[keyof typeof SocketEventName];
 
 
@@ -22,3 +26,5 @@ export type PMStartedData = { total: number };
 export type PMMigratingData = { count: number };
 export type PMMigratingData = { count: number };
 export type PMErrorCountData = { skip: number };
 export type PMErrorCountData = { skip: number };
 export type PMEndedData = { isSucceeded: boolean };
 export type PMEndedData = { isSucceeded: boolean };
+
+export type PageMigrationErrorData = { paths: string[] }

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

@@ -350,9 +350,9 @@ module.exports = (crowi) => {
         user: req.user._id,
         user: req.user._id,
         targetModel: SUPPORTED_TARGET_MODEL_TYPE.MODEL_PAGE,
         targetModel: SUPPORTED_TARGET_MODEL_TYPE.MODEL_PAGE,
         target: createdPage,
         target: createdPage,
-        action: SUPPORTED_ACTION_TYPE.PAGE_CREATE,
+        action: SUPPORTED_ACTION_TYPE.ACTION_PAGE_CREATE,
       };
       };
-      await crowi.activityService.createByParameter(parameters);
+      await crowi.activityService.createByParameters(parameters);
     }
     }
     catch (err) {
     catch (err) {
       logger.error('Failed to create activity', err);
       logger.error('Failed to create activity', err);
@@ -834,7 +834,12 @@ module.exports = (crowi) => {
     }
     }
 
 
     try {
     try {
-      await crowi.pageService.normalizeParentByPageIds(pageIds, req.user, isRecursively);
+      if (isRecursively) {
+        await crowi.pageService.normalizeParentByPageIdsRecursively(pageIds, req.user);
+      }
+      else {
+        await crowi.pageService.normalizeParentByPageIds(pageIds, req.user);
+      }
     }
     }
     catch (err) {
     catch (err) {
       return res.apiv3Err(new ErrorV3(`Failed to migrate pages: ${err.message}`), 500);
       return res.apiv3Err(new ErrorV3(`Failed to migrate pages: ${err.message}`), 500);

+ 41 - 20
packages/app/src/server/service/page.ts

@@ -8,6 +8,7 @@ import streamToPromise from 'stream-to-promise';
 
 
 import { SUPPORTED_TARGET_MODEL_TYPE, SUPPORTED_ACTION_TYPE } from '~/interfaces/activity';
 import { SUPPORTED_TARGET_MODEL_TYPE, SUPPORTED_ACTION_TYPE } from '~/interfaces/activity';
 import { Ref } from '~/interfaces/common';
 import { Ref } from '~/interfaces/common';
+import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
 import { HasObjectId } from '~/interfaces/has-object-id';
 import { HasObjectId } from '~/interfaces/has-object-id';
 import {
 import {
   IPage, IPageInfo, IPageInfoForEntity, IPageWithMeta,
   IPage, IPageInfo, IPageInfoForEntity, IPageWithMeta,
@@ -16,7 +17,7 @@ import {
   PageDeleteConfigValue, IPageDeleteConfigValueToProcessValidation,
   PageDeleteConfigValue, IPageDeleteConfigValueToProcessValidation,
 } from '~/interfaces/page-delete-config';
 } from '~/interfaces/page-delete-config';
 import { IUserHasId } from '~/interfaces/user';
 import { IUserHasId } from '~/interfaces/user';
-import { SocketEventName, UpdateDescCountRawData } from '~/interfaces/websocket';
+import { PageMigrationErrorData, SocketEventName, UpdateDescCountRawData } from '~/interfaces/websocket';
 import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
 import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
 import {
 import {
   CreateMethod, PageCreateOptions, PageModel, PageDocument,
   CreateMethod, PageCreateOptions, PageModel, PageDocument,
@@ -32,7 +33,6 @@ import { PageRedirectModel } from '../models/page-redirect';
 import { serializePageSecurely } from '../models/serializers/page-serializer';
 import { serializePageSecurely } from '../models/serializers/page-serializer';
 import Subscription from '../models/subscription';
 import Subscription from '../models/subscription';
 import { V5ConversionError } from '../models/vo/v5-conversion-error';
 import { V5ConversionError } from '../models/vo/v5-conversion-error';
-import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
 
 
 const debug = require('debug')('growi:services:page');
 const debug = require('debug')('growi:services:page');
 
 
@@ -2315,24 +2315,37 @@ class PageService {
     this.normalizeParentRecursivelyMainOperation(page, user, pageOp._id);
     this.normalizeParentRecursivelyMainOperation(page, user, pageOp._id);
   }
   }
 
 
-  async normalizeParentByPageIds(pageIds: ObjectIdLike[], user, isRecursively: boolean): Promise<void> {
+  async normalizeParentByPageIdsRecursively(pageIds: ObjectIdLike[], user): Promise<void> {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
 
 
-    if (isRecursively) {
-      const pages = await Page.findByIdsAndViewer(pageIds, user, null);
+    const pages = await Page.findByIdsAndViewer(pageIds, user, null);
 
 
-      // DO NOT await !!
-      this.normalizeParentRecursivelyByPages(pages, user);
+    if (pages == null || pages.length === 0) {
+      throw Error('pageIds is null or 0 length.');
+    }
 
 
-      return;
+    if (pages.length > LIMIT_FOR_MULTIPLE_PAGE_OP) {
+      throw Error(`The maximum number of pageIds allowed is ${LIMIT_FOR_MULTIPLE_PAGE_OP}.`);
     }
     }
 
 
+    this.normalizeParentRecursivelyByPages(pages, user);
+
+    return;
+  }
+
+  async normalizeParentByPageIds(pageIds: ObjectIdLike[], user): Promise<void> {
+    const Page = await mongoose.model('Page') as unknown as PageModel;
+
+    const socket = this.crowi.socketIoService.getDefaultSocket();
+
     for await (const pageId of pageIds) {
     for await (const pageId of pageIds) {
       const page = await Page.findById(pageId);
       const page = await Page.findById(pageId);
       if (page == null) {
       if (page == null) {
         continue;
         continue;
       }
       }
 
 
+      const errorData: PageMigrationErrorData = { paths: [page.path] };
+
       try {
       try {
         const canOperate = await this.crowi.pageOperationService.canOperate(false, page.path, page.path);
         const canOperate = await this.crowi.pageOperationService.canOperate(false, page.path, page.path);
         if (!canOperate) {
         if (!canOperate) {
@@ -2342,14 +2355,16 @@ class PageService {
         const normalizedPage = await this.normalizeParentByPage(page, user);
         const normalizedPage = await this.normalizeParentByPage(page, user);
 
 
         if (normalizedPage == null) {
         if (normalizedPage == null) {
+          socket.emit(SocketEventName.PageMigrationError, errorData);
           logger.error(`Failed to update descendantCount of page of id: "${pageId}"`);
           logger.error(`Failed to update descendantCount of page of id: "${pageId}"`);
         }
         }
       }
       }
       catch (err) {
       catch (err) {
+        socket.emit(SocketEventName.PageMigrationError, errorData);
         logger.error('Something went wrong while normalizing parent.', err);
         logger.error('Something went wrong while normalizing parent.', err);
-        // socket.emit('normalizeParentByPageIds', { error: err.message }); TODO: use socket to tell user
       }
       }
     }
     }
+    socket.emit(SocketEventName.PageMigrationSuccess);
   }
   }
 
 
   private async normalizeParentByPage(page, user) {
   private async normalizeParentByPage(page, user) {
@@ -2412,14 +2427,7 @@ class PageService {
     /*
     /*
      * Main Operation
      * Main Operation
      */
      */
-    if (pages == null || pages.length === 0) {
-      logger.error('pageIds is null or 0 length.');
-      return;
-    }
-
-    if (pages.length > LIMIT_FOR_MULTIPLE_PAGE_OP) {
-      throw Error(`The maximum number of pageIds allowed is ${LIMIT_FOR_MULTIPLE_PAGE_OP}.`);
-    }
+    const socket = this.crowi.socketIoService.getDefaultSocket();
 
 
     const pagesToNormalize = omitDuplicateAreaPageFromPages(pages);
     const pagesToNormalize = omitDuplicateAreaPageFromPages(pages);
 
 
@@ -2429,25 +2437,29 @@ class PageService {
       [normalizablePages, nonNormalizablePages] = await this.crowi.pageGrantService.separateNormalizableAndNotNormalizablePages(user, pagesToNormalize);
       [normalizablePages, nonNormalizablePages] = await this.crowi.pageGrantService.separateNormalizableAndNotNormalizablePages(user, pagesToNormalize);
     }
     }
     catch (err) {
     catch (err) {
+      socket.emit(SocketEventName.PageMigrationError);
       throw err;
       throw err;
     }
     }
 
 
     if (normalizablePages.length === 0) {
     if (normalizablePages.length === 0) {
-      // socket.emit('normalizeParentRecursivelyByPages', { error: err.message }); TODO: use socket to tell user
+      socket.emit(SocketEventName.PageMigrationError);
       return;
       return;
     }
     }
 
 
     if (nonNormalizablePages.length !== 0) {
     if (nonNormalizablePages.length !== 0) {
-      // TODO: iterate nonNormalizablePages and send socket error to client so that the user can know which path failed to migrate
-      // socket.emit('normalizeParentRecursivelyByPages', { error: err.message }); TODO: use socket to tell user
+      const nonNormalizablePagePaths: string[] = nonNormalizablePages.map(p => p.path);
+      socket.emit(SocketEventName.PageMigrationError, { paths: nonNormalizablePagePaths });
+      logger.debug('Some pages could not be converted.', nonNormalizablePagePaths);
     }
     }
 
 
     /*
     /*
      * Main Operation (s)
      * Main Operation (s)
      */
      */
+    const errorPagePaths: string[] = [];
     for await (const page of normalizablePages) {
     for await (const page of normalizablePages) {
       const canOperate = await this.crowi.pageOperationService.canOperate(true, page.path, page.path);
       const canOperate = await this.crowi.pageOperationService.canOperate(true, page.path, page.path);
       if (!canOperate) {
       if (!canOperate) {
+        errorPagePaths.push(page.path);
         throw Error(`Cannot operate normalizeParentRecursiively to path "${page.path}" right now.`);
         throw Error(`Cannot operate normalizeParentRecursiively to path "${page.path}" right now.`);
       }
       }
 
 
@@ -2459,6 +2471,7 @@ class PageService {
       const existingPage = await builder.query.exec();
       const existingPage = await builder.query.exec();
 
 
       if (existingPage?.parent != null) {
       if (existingPage?.parent != null) {
+        errorPagePaths.push(page.path);
         throw Error('This page has already converted.');
         throw Error('This page has already converted.');
       }
       }
 
 
@@ -2474,6 +2487,7 @@ class PageService {
         });
         });
       }
       }
       catch (err) {
       catch (err) {
+        errorPagePaths.push(page.path);
         logger.error('Failed to create PageOperation document.', err);
         logger.error('Failed to create PageOperation document.', err);
         throw err;
         throw err;
       }
       }
@@ -2482,10 +2496,17 @@ class PageService {
         await this.normalizeParentRecursivelyMainOperation(page, user, pageOp._id);
         await this.normalizeParentRecursivelyMainOperation(page, user, pageOp._id);
       }
       }
       catch (err) {
       catch (err) {
+        errorPagePaths.push(page.path);
         logger.err('Failed to run normalizeParentRecursivelyMainOperation.', err);
         logger.err('Failed to run normalizeParentRecursivelyMainOperation.', err);
         throw err;
         throw err;
       }
       }
     }
     }
+    if (errorPagePaths.length === 0) {
+      socket.emit(SocketEventName.PageMigrationSuccess);
+    }
+    else {
+      socket.emit(SocketEventName.PageMigrationError, { paths: errorPagePaths });
+    }
   }
   }
 
 
   async normalizeParentRecursivelyMainOperation(page, user, pageOpId: ObjectIdLike): Promise<void> {
   async normalizeParentRecursivelyMainOperation(page, user, pageOpId: ObjectIdLike): Promise<void> {