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

Merge branch 'master' into support/107699-next-PageStatusAlert

Yuken Tezuka 3 лет назад
Родитель
Сommit
a1bc20e7de
54 измененных файлов с 904 добавлено и 832 удалено
  1. 2 1
      packages/app/cypress.json
  2. 17 0
      packages/app/public/static/locales/en_US/commons.json
  3. 17 0
      packages/app/public/static/locales/ja_JP/commons.json
  4. 17 0
      packages/app/public/static/locales/zh_CN/commons.json
  5. 1 0
      packages/app/src/components/Comments.tsx
  6. 1 1
      packages/app/src/components/Navbar/PageEditorModeManager.jsx
  7. 0 68
      packages/app/src/components/Page.tsx
  8. 2 2
      packages/app/src/components/PageAccessoriesModal.tsx
  9. 1 0
      packages/app/src/components/PageComment.tsx
  10. 1 0
      packages/app/src/components/PageComment/Comment.tsx
  11. 3 3
      packages/app/src/components/PageComment/CommentEditor.tsx
  12. 15 12
      packages/app/src/components/PageCreateModal.jsx
  13. 21 16
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  14. 1 1
      packages/app/src/components/PageEditor/Editor.tsx
  15. 0 537
      packages/app/src/components/PageEditor/HandsontableModal.jsx
  16. 27 5
      packages/app/src/components/PageEditor/HandsontableModal.module.scss
  17. 506 0
      packages/app/src/components/PageEditor/HandsontableModal.tsx
  18. 0 103
      packages/app/src/components/PageEditor/MarkdownTableDataImportForm.jsx
  19. 98 0
      packages/app/src/components/PageEditor/MarkdownTableDataImportForm.tsx
  20. 8 2
      packages/app/src/components/PageHistory.tsx
  21. 6 4
      packages/app/src/components/PageHistory/PageRevisionTable.tsx
  22. 11 5
      packages/app/src/components/PageHistory/Revision.tsx
  23. 15 8
      packages/app/src/components/PageHistory/RevisionDiff.tsx
  24. 3 1
      packages/app/src/components/RevisionComparer/RevisionComparer.tsx
  25. 10 2
      packages/app/src/pages/[[...path]].page.tsx
  26. 2 0
      packages/app/src/pages/admin/[...path].page.tsx
  27. 4 0
      packages/app/src/pages/admin/app.page.tsx
  28. 2 1
      packages/app/src/pages/admin/audit-log.page.tsx
  29. 2 1
      packages/app/src/pages/admin/customize.page.tsx
  30. 2 0
      packages/app/src/pages/admin/export.page.tsx
  31. 3 0
      packages/app/src/pages/admin/global-notification/[globalNotificationId].page.tsx
  32. 2 0
      packages/app/src/pages/admin/global-notification/new.page.tsx
  33. 2 0
      packages/app/src/pages/admin/importer.page.tsx
  34. 2 2
      packages/app/src/pages/admin/index.page.tsx
  35. 3 0
      packages/app/src/pages/admin/markdown.page.tsx
  36. 2 0
      packages/app/src/pages/admin/notification.page.tsx
  37. 2 1
      packages/app/src/pages/admin/search.page.tsx
  38. 2 1
      packages/app/src/pages/admin/security.page.tsx
  39. 2 0
      packages/app/src/pages/admin/slack-integration-legacy.page.tsx
  40. 2 1
      packages/app/src/pages/admin/slack-integration.page.tsx
  41. 2 1
      packages/app/src/pages/admin/user-group-detail/[userGroupId].page.tsx
  42. 2 1
      packages/app/src/pages/admin/user-groups.page.tsx
  43. 2 0
      packages/app/src/pages/admin/users/external-accounts.page.tsx
  44. 2 6
      packages/app/src/pages/admin/users/index.page.tsx
  45. 1 4
      packages/app/src/pages/me/[[...path]].page.tsx
  46. 1 6
      packages/app/src/pages/share/[[...path]].page.tsx
  47. 12 3
      packages/app/src/pages/utils/commons.ts
  48. 4 2
      packages/app/src/server/routes/apiv3/page.js
  49. 2 2
      packages/app/src/stores/context.tsx
  50. 38 3
      packages/app/src/stores/modal.tsx
  51. 7 5
      packages/app/src/stores/page.tsx
  52. 0 19
      packages/app/src/styles/style-next.scss
  53. 13 2
      packages/app/src/utils/admin-page-util.ts
  54. 1 0
      packages/app/test/cypress/integration/20-basic-features/use-tools.spec.ts

+ 2 - 1
packages/app/cypress.json

@@ -13,5 +13,6 @@
   "viewportWidth": 1400,
   "viewportHeight": 1024,
 
-  "experimentalSessionSupport": true
+  "experimentalSessionSupport": true,
+  "defaultCommandTimeout": 30000
 }

+ 17 - 0
packages/app/public/static/locales/en_US/commons.json

@@ -59,6 +59,23 @@
     "cancel": "Cancel"
   },
 
+  "handsontable_modal": {
+    "title": "Edit Table",
+    "data_import": "Data Import",
+    "save": "Save",
+    "reset": "Reset",
+    "cancel": "Cancel",
+    "done": "Done",
+    "data_import_form": {
+      "select_data_format": "Select Data Format",
+      "import_data": "Import Data",
+      "paste_table_data": "Paste table data",
+      "parse_error": "Parse Error",
+      "cancel": "Cancel",
+      "import": "Import"
+    }
+  },
+
   "not_found_page": {
     "page_not_exist": "This page does not exist."
   }

+ 17 - 0
packages/app/public/static/locales/ja_JP/commons.json

@@ -59,6 +59,23 @@
     "cancel": "キャンセル"
   },
 
+  "handsontable_modal": {
+    "title": "テーブル編集",
+    "data_import": "データインポート",
+    "save": "保存",
+    "reset": "リセット",
+    "cancel": "キャンセル",
+    "done": "完了",
+    "data_import_form": {
+      "select_data_format": "データフォーマット",
+      "import_data": "インポートデータ",
+      "paste_table_data": "テーブルデータを貼り付け",
+      "parse_error": "パーザーエラー",
+      "cancel": "キャンセル",
+      "import": "インポート"
+    }
+  },
+
   "not_found_page": {
     "page_not_exist": "このページは存在しません。"
   }

+ 17 - 0
packages/app/public/static/locales/zh_CN/commons.json

@@ -59,6 +59,23 @@
     "cancel": "取消"
   },
 
+  "handsontable_modal": {
+    "title": "编辑表格",
+    "data_import": "数据导入",
+    "save": "节省",
+    "reset": "重启",
+    "cancel": "取消",
+    "done": "完毕",
+    "data_import_form": {
+      "select_data_format": "数据格式",
+      "import_data": "导入数据",
+      "paste_table_data": "粘贴表格数据",
+      "parse_error": "解析错误",
+      "cancel": "取消",
+      "import": "导入"
+    }
+  },
+
   "not_found_page": {
     "page_not_exist": "该页面不存在"
   }

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

@@ -53,6 +53,7 @@ export const Comments = (props: CommentsProps): JSX.Element => {
                 pageId={pageId}
                 isForNewComment
                 onCommentButtonClicked={mutate}
+                revisionId={revision._id}
               />
             </div>
           )}

+ 1 - 1
packages/app/src/components/Navbar/PageEditorModeManager.jsx

@@ -27,7 +27,7 @@ const PageEditorModeButtonWrapper = React.memo(({
       className={classNames.join(' ')}
       onClick={() => { onClick(targetMode) }}
       id={id}
-      data-testId={`${targetMode}-button`}
+      data-testid={`${targetMode}-button`}
     >
       <span className="d-flex flex-column flex-md-row justify-content-center">
         <span className="grw-page-editor-mode-manager-icon mr-md-1">{icon}</span>

+ 0 - 68
packages/app/src/components/Page.tsx

@@ -28,16 +28,13 @@ import loggerFactory from '~/utils/logger';
 
 import RevisionRenderer from './Page/RevisionRenderer';
 import { DrawioModal } from './PageEditor/DrawioModal';
-// import MarkdownTable from '~/client/models/MarkdownTable';
 import mdu from './PageEditor/MarkdownDrawioUtil';
-import mtu from './PageEditor/MarkdownTableUtil';
 
 
 declare const globalEmitter: EventEmitter;
 
 // const DrawioModal = dynamic(() => import('./PageEditor/DrawioModal'), { ssr: false });
 const GridEditModal = dynamic(() => import('./PageEditor/GridEditModal'), { ssr: false });
-const HandsontableModal = dynamic(() => import('./PageEditor/HandsontableModal'), { ssr: false });
 const LinkEditModal = dynamic(() => import('./PageEditor/LinkEditModal'), { ssr: false });
 
 const logger = loggerFactory('growi:Page');
@@ -59,8 +56,6 @@ class PageSubstance extends React.Component<PageSubstanceProps> {
 
   linkEditModal: any;
 
-  handsontableModal: any;
-
   drawioModal: any;
 
   constructor(props: PageSubstanceProps) {
@@ -73,25 +68,11 @@ class PageSubstance extends React.Component<PageSubstanceProps> {
 
     this.gridEditModal = React.createRef();
     this.linkEditModal = React.createRef();
-    this.handsontableModal = React.createRef();
     this.drawioModal = React.createRef();
 
-    this.saveHandlerForHandsontableModal = this.saveHandlerForHandsontableModal.bind(this);
     this.saveHandlerForDrawioModal = this.saveHandlerForDrawioModal.bind(this);
   }
 
-  /**
-   * launch HandsontableModal with data specified by arguments
-   * @param beginLineNumber
-   * @param endLineNumber
-   */
-  launchHandsontableModal(beginLineNumber, endLineNumber) {
-    // const markdown = this.props.pageContainer.state.markdown;
-    // const tableLines = markdown.split(/\r\n|\r|\n/).slice(beginLineNumber - 1, endLineNumber).join('\n');
-    // this.setState({ currentTargetTableArea: { beginLineNumber, endLineNumber } });
-    // this.handsontableModal.current.show(MarkdownTable.fromMarkdownString(tableLines));
-  }
-
   /**
    * launch DrawioModal with data specified by arguments
    * @param beginLineNumber
@@ -105,39 +86,6 @@ class PageSubstance extends React.Component<PageSubstanceProps> {
     // this.drawioModal.current.show(drawioData);
   }
 
-  async saveHandlerForHandsontableModal(markdownTable) {
-    // const {
-    //   isSlackEnabled, slackChannels, pageContainer, mutateIsEnabledUnsavedWarning, grant, grantGroupId, grantGroupName, pageTags,
-    // } = this.props;
-    // const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
-
-    // const newMarkdown = mtu.replaceMarkdownTableInMarkdown(
-    //   markdownTable,
-    //   this.props.pageContainer.state.markdown,
-    //   this.state.currentTargetTableArea.beginLineNumber,
-    //   this.state.currentTargetTableArea.endLineNumber,
-    // );
-
-    // try {
-    //   // disable unsaved warning
-    //   mutateIsEnabledUnsavedWarning(false);
-
-    //   // eslint-disable-next-line no-unused-vars
-    //   const { page, tags } = await pageContainer.save(newMarkdown, this.props.editorMode, optionsToSave);
-    //   logger.debug('success to save');
-
-    // // Todo: add translation
-    // toastSuccess(t(''));
-    // }
-    // catch (error) {
-    //   logger.error('failed to save', error);
-    // toastError(error);
-    // }
-    // finally {
-    //   this.setState({ currentTargetTableArea: null });
-    // }
-  }
-
   async saveHandlerForDrawioModal(drawioData) {
   //   const {
   //     isSlackEnabled, slackChannels, pageContainer, pageTags, grant, grantGroupId, grantGroupName, mutateIsEnabledUnsavedWarning,
@@ -189,7 +137,6 @@ class PageSubstance extends React.Component<PageSubstanceProps> {
           <>
             <GridEditModal ref={this.gridEditModal} />
             <LinkEditModal ref={this.linkEditModal} />
-            <HandsontableModal ref={this.handsontableModal} onSave={this.saveHandlerForHandsontableModal} />
             {/* TODO: use global DrawioModal https://redmine.weseek.co.jp/issues/105981 */}
             {/* <DrawioModal
               ref={this.drawioModal}
@@ -245,20 +192,6 @@ export const Page = (props) => {
   //   };
   // }, []);
 
-  // // set handler to open HandsontableModal
-  // useEffect(() => {
-  //   const handler = (beginLineNumber, endLineNumber) => {
-  //     if (pageRef?.current != null) {
-  //       pageRef.current.launchHandsontableModal(beginLineNumber, endLineNumber);
-  //     }
-  //   };
-  //   window.globalEmitter.on('launchHandsontableModal', handler);
-
-  //   return function cleanup() {
-  //     window.globalEmitter.removeListener('launchHandsontableModal', handler);
-  //   };
-  // }, []);
-
   if (currentPage == null || editorMode == null || isGuestUser == null || rendererOptions == null) {
     const entries = Object.entries({
       currentPage, editorMode, isGuestUser, rendererOptions,
@@ -270,7 +203,6 @@ export const Page = (props) => {
     return null;
   }
 
-
   return (
     <PageSubstance
       {...props}

+ 2 - 2
packages/app/src/components/PageAccessoriesModal.tsx

@@ -57,7 +57,7 @@ const PageAccessoriesModal = (): JSX.Element => {
           if (!isOpened) {
             return <></>;
           }
-          return <PageHistory />;
+          return <PageHistory onClose={close}/>;
         },
         i18n: t('History'),
         index: 0,
@@ -87,7 +87,7 @@ const PageAccessoriesModal = (): JSX.Element => {
         isLinkEnabled: () => !isGuestUser && !isSharedUser && !isLinkSharingDisabled,
       },
     };
-  }, [status, t, isGuestUser, isSharedUser, isLinkSharingDisabled]);
+  }, [status, t, close, isGuestUser, isSharedUser, isLinkSharingDisabled]);
 
   const buttons = useMemo(() => (
     <div className="d-flex flex-nowrap">

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

@@ -200,6 +200,7 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
                         removeShowEditorId(comment._id);
                         mutate();
                       }}
+                      revisionId={revisionId}
                     />
                   )}
                 </div>

+ 1 - 0
packages/app/src/components/PageComment/Comment.tsx

@@ -138,6 +138,7 @@ export const Comment = (props: CommentProps): JSX.Element => {
             setIsReEdit(false);
             if (onComment != null) onComment();
           }}
+          revisionId={revisionId}
         />
       ) : (
         <div id={commentId} className={rootClassName}>

+ 3 - 3
packages/app/src/components/PageComment/CommentEditor.tsx

@@ -13,7 +13,7 @@ import { apiPostForm } from '~/client/util/apiv1-client';
 import { IEditorMethods } from '~/interfaces/editor-methods';
 import { useSWRxPageComment } from '~/stores/comment';
 import {
-  useCurrentUser, useRevisionId, useIsSlackConfigured,
+  useCurrentUser, useIsSlackConfigured,
   useIsUploadableFile, useIsUploadableImage,
 } from '~/stores/context';
 import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
@@ -49,6 +49,7 @@ export type CommentEditorProps = {
   pageId: string,
   isForNewComment?: boolean,
   replyTo?: string,
+  revisionId: string,
   currentCommentId?: string,
   commentBody?: string,
   onCancelButtonClicked?: () => void,
@@ -59,14 +60,13 @@ export type CommentEditorProps = {
 export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
 
   const {
-    pageId, isForNewComment, replyTo,
+    pageId, isForNewComment, replyTo, revisionId,
     currentCommentId, commentBody, onCancelButtonClicked, onCommentButtonClicked,
   } = props;
 
   const { data: currentUser } = useCurrentUser();
   const { data: currentPagePath } = useCurrentPagePath();
   const { update: updateComment, post: postComment } = useSWRxPageComment(pageId);
-  const { data: revisionId } = useRevisionId();
   const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isSlackConfigured } = useIsSlackConfigured();

+ 15 - 12
packages/app/src/components/PageCreateModal.jsx

@@ -6,7 +6,9 @@ import { pagePathUtils, pathUtils } from '@growi/core';
 import { format } from 'date-fns';
 import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
-import { Modal, ModalHeader, ModalBody } from 'reactstrap';
+import {
+  Modal, ModalHeader, ModalBody, UncontrolledButtonDropdown, DropdownToggle, DropdownMenu, DropdownItem,
+} from 'reactstrap';
 import { debounce } from 'throttle-debounce';
 
 
@@ -270,29 +272,30 @@ const PageCreateModal = () => {
 
           <div className="d-sm-flex align-items-center justify-items-between">
 
-            <div id="dd-template-type" className="dropdown flex-fill">
-              <button id="template-type" type="button" className="btn btn-secondary btn dropdown-toggle w-100" data-toggle="dropdown">
+            <UncontrolledButtonDropdown id="dd-template-type" className='flex-fill text-center'>
+              <DropdownToggle caret>
                 {template == null && t('template.option_label.select')}
                 {template === 'children' && t('template.children.label')}
                 {template === 'decendants' && t('template.decendants.label')}
-              </button>
-              <div className="dropdown-menu" aria-labelledby="userMenu">
-                <button className="dropdown-item" type="button" onClick={() => onChangeTemplateHandler('children')}>
+              </DropdownToggle>
+              <DropdownMenu>
+                <DropdownItem onClick={() => onChangeTemplateHandler('children')}>
                   {t('template.children.label')} (_template)<br className="d-block d-md-none" />
                   <small className="text-muted text-wrap">- {t('template.children.desc')}</small>
-                </button>
-                <button className="dropdown-item" type="button" onClick={() => onChangeTemplateHandler('decendants')}>
+                </DropdownItem>
+                <DropdownItem onClick={() => onChangeTemplateHandler('decendants')}>
                   {t('template.decendants.label')} (__template) <br className="d-block d-md-none" />
                   <small className="text-muted">- {t('template.decendants.desc')}</small>
-                </button>
-              </div>
-            </div>
+                </DropdownItem>
+              </DropdownMenu>
+            </UncontrolledButtonDropdown>
 
             <div className="d-flex justify-content-end mt-1 mt-sm-0">
               <button
                 type="button"
-                className={`grw-btn-create-page btn btn-outline-primary rounded-pill text-nowrap ml-3 ${template == null && 'disabled'}`}
+                className='grw-btn-create-page btn btn-outline-primary rounded-pill text-nowrap ml-3'
                 onClick={createTemplatePage}
+                disabled={template == null}
               >
                 <i className="icon-fw icon-doc"></i>{t('Edit')}
               </button>

+ 21 - 16
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -11,7 +11,7 @@ import { throttle, debounce } from 'throttle-debounce';
 import urljoin from 'url-join';
 
 import InterceptorManager from '~/services/interceptor-manager';
-import { useDrawioModal } from '~/stores/modal';
+import { useHandsontableModal, useDrawioModal } from '~/stores/modal';
 import loggerFactory from '~/utils/logger';
 
 import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
@@ -25,7 +25,6 @@ import EmojiPickerHelper from './EmojiPickerHelper';
 import GridEditModal from './GridEditModal';
 // TODO: re-impl with https://redmine.weseek.co.jp/issues/107248
 // import geu from './GridEditorUtil';
-import HandsontableModal from './HandsontableModal';
 import LinkEditModal from './LinkEditModal';
 import mdu from './MarkdownDrawioUtil';
 import markdownLinkUtil from './MarkdownLinkUtil';
@@ -116,7 +115,6 @@ class CodeMirrorEditor extends AbstractEditor {
     this.cm = React.createRef();
     this.gridEditModal = React.createRef();
     this.linkEditModal = React.createRef();
-    this.handsontableModal = React.createRef();
     this.drawioModal = React.createRef();
 
     this.init();
@@ -156,7 +154,6 @@ class CodeMirrorEditor extends AbstractEditor {
     // TODO: re-impl with https://redmine.weseek.co.jp/issues/107248
     // this.showGridEditorHandler = this.showGridEditorHandler.bind(this);
     this.showLinkEditHandler = this.showLinkEditHandler.bind(this);
-    this.showHandsonTableHandler = this.showHandsonTableHandler.bind(this);
 
     this.foldDrawioSection = this.foldDrawioSection.bind(this);
     this.onSaveForDrawio = this.onSaveForDrawio.bind(this);
@@ -869,11 +866,6 @@ class CodeMirrorEditor extends AbstractEditor {
     this.linkEditModal.current.show(markdownLinkUtil.getMarkdownLink(this.getCodeMirror()));
   }
 
-  showHandsonTableHandler() {
-    this.handsontableModal.current.show(mtu.getMarkdownTable(this.getCodeMirror()));
-  }
-
-
   // fold draw.io section (::: drawio ~ :::)
   foldDrawioSection() {
     const editor = this.getCodeMirror();
@@ -1016,7 +1008,13 @@ class CodeMirrorEditor extends AbstractEditor {
         color={null}
         size="sm"
         title="Table"
-        onClick={this.showHandsonTableHandler}
+        onClick={() => {
+          this.props.onClickTableBtn(
+            mtu.getMarkdownTable(this.getCodeMirror()),
+            this.getCodeMirror(),
+            this.props.editorSettings.autoFormatMarkdownTable,
+          );
+        }}
       >
         <EditorIcon icon="Table" />
       </Button>,
@@ -1131,11 +1129,6 @@ class CodeMirrorEditor extends AbstractEditor {
           ref={this.linkEditModal}
           onSave={(linkText) => { return markdownLinkUtil.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), linkText) }}
         />
-        <HandsontableModal
-          ref={this.handsontableModal}
-          onSave={(table) => { return mtu.replaceFocusedMarkdownTableWithEditor(this.getCodeMirror(), table) }}
-          autoFormatMarkdownTable={this.props.editorSettings.autoFormatMarkdownTable}
-        />
       </div>
     );
   }
@@ -1157,12 +1150,24 @@ CodeMirrorEditor.defaultProps = {
 
 const CodeMirrorEditorFc = React.forwardRef((props, ref) => {
   const { open: openDrawioModal } = useDrawioModal();
+  const { open: openHandsontableModal } = useHandsontableModal();
 
   const openDrawioModalHandler = useCallback((drawioMxFile) => {
     openDrawioModal(drawioMxFile);
   }, [openDrawioModal]);
 
-  return <CodeMirrorEditor ref={ref} onClickDrawioBtn={openDrawioModalHandler} {...props} />;
+  const openTableModalHandler = useCallback((table, editor, autoFormatMarkdownTable) => {
+    openHandsontableModal(table, editor, autoFormatMarkdownTable);
+  }, [openHandsontableModal]);
+
+  return (
+    <CodeMirrorEditor
+      ref={ref}
+      onClickDrawioBtn={openDrawioModalHandler}
+      onClickTableBtn={openTableModalHandler}
+      {...props}
+    />
+  );
 });
 
 CodeMirrorEditorFc.displayName = 'CodeMirrorEditorFc';

+ 1 - 1
packages/app/src/components/PageEditor/Editor.tsx

@@ -229,7 +229,7 @@ const Editor: ForwardRefRenderFunction<IEditorMethods, EditorPropsType> = (props
 
   const renderNavbar = useCallback(() => {
     return (
-      <div className="m-0 navbar navbar-default navbar-editor" data-testId="navbar-editor" style={{ minHeight: 'unset' }}>
+      <div className="m-0 navbar navbar-default navbar-editor" data-testid="navbar-editor" style={{ minHeight: 'unset' }}>
         <ul className="pl-2 nav nav-navbar">
           { (editorSubstance()?.getNavbarItems() ?? []).map((item, idx) => {
             // eslint-disable-next-line react/no-array-index-key

+ 0 - 537
packages/app/src/components/PageEditor/HandsontableModal.jsx

@@ -1,537 +0,0 @@
-import React from 'react';
-
-import { HotTable } from '@handsontable/react';
-import Handsontable from 'handsontable';
-import PropTypes from 'prop-types';
-import {
-  Collapse,
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
-import { debounce } from 'throttle-debounce';
-
-
-import MarkdownTable from '~/client/models/MarkdownTable';
-
-import ExpandOrContractButton from '../ExpandOrContractButton';
-
-import MarkdownTableDataImportForm from './MarkdownTableDataImportForm';
-
-import styles from './HandsontableModal.module.scss';
-import 'handsontable/dist/handsontable.full.min.css';
-
-const DEFAULT_HOT_HEIGHT = 300;
-const MARKDOWNTABLE_TO_HANDSONTABLE_ALIGNMENT_SYMBOL_MAPPING = {
-  r: 'htRight',
-  c: 'htCenter',
-  l: 'htLeft',
-  '': '',
-};
-
-export default class HandsontableModal extends React.PureComponent {
-
-  constructor(props) {
-    super(props);
-
-    /*
-     * ## Note ##
-     * Currently, this component try to synchronize the cells data and alignment data of state.markdownTable with these of the HotTable.
-     * However, changes made by the following operations are not synchronized.
-     *
-     * 1. move columns: Alignment changes are synchronized but data changes are not.
-     * 2. move rows: Data changes are not synchronized.
-     * 3. insert columns or rows: Data changes are synchronized but alignment changes are not.
-     * 4. delete columns or rows: Data changes are synchronized but alignment changes are not.
-     *
-     * However, all operations are reflected in the data to be saved because the HotTable data is used when the save method is called.
-     */
-    this.state = {
-      show: false,
-      isDataImportAreaExpanded: false,
-      isWindowExpanded: false,
-      markdownTableOnInit: HandsontableModal.getDefaultMarkdownTable(),
-      markdownTable: HandsontableModal.getDefaultMarkdownTable(),
-      handsontableHeight: DEFAULT_HOT_HEIGHT,
-    };
-
-    this.init = this.init.bind(this);
-    this.reset = this.reset.bind(this);
-    this.cancel = this.cancel.bind(this);
-    this.save = this.save.bind(this);
-    this.afterLoadDataHandler = this.afterLoadDataHandler.bind(this);
-    this.beforeColumnResizeHandler = this.beforeColumnResizeHandler.bind(this);
-    this.afterColumnResizeHandler = this.afterColumnResizeHandler.bind(this);
-    this.modifyColWidthHandler = this.modifyColWidthHandler.bind(this);
-    this.beforeColumnMoveHandler = this.beforeColumnMoveHandler.bind(this);
-    this.afterColumnMoveHandler = this.afterColumnMoveHandler.bind(this);
-    this.synchronizeAlignment = this.synchronizeAlignment.bind(this);
-    this.alignButtonHandler = this.alignButtonHandler.bind(this);
-    this.toggleDataImportArea = this.toggleDataImportArea.bind(this);
-    this.importData = this.importData.bind(this);
-    this.expandWindow = this.expandWindow.bind(this);
-    this.contractWindow = this.contractWindow.bind(this);
-
-    // create debounced method for expanding HotTable
-    this.expandHotTableHeightWithDebounce = debounce(100, this.expandHotTableHeight);
-
-    // a Set instance that stores column indices which are resized manually.
-    // these columns will NOT be determined the width automatically by 'modifyColWidthHandler'
-    this.manuallyResizedColumnIndicesSet = new Set();
-
-    // generate setting object for HotTable instance
-    this.handsontableSettings = Object.assign(HandsontableModal.getDefaultHandsontableSetting(), {
-      contextMenu: this.createCustomizedContextMenu(),
-    });
-  }
-
-  init(markdownTable) {
-    const initMarkdownTable = markdownTable || HandsontableModal.getDefaultMarkdownTable();
-    this.setState(
-      {
-        markdownTableOnInit: initMarkdownTable,
-        markdownTable: initMarkdownTable.clone(),
-      },
-    );
-
-    this.manuallyResizedColumnIndicesSet.clear();
-  }
-
-  createCustomizedContextMenu() {
-    return {
-      items: {
-        row_above: {},
-        row_below: {},
-        col_left: {},
-        col_right: {},
-        separator1: Handsontable.plugins.ContextMenu.SEPARATOR,
-        remove_row: {},
-        remove_col: {},
-        separator2: Handsontable.plugins.ContextMenu.SEPARATOR,
-        custom_alignment: {
-          name: 'Align columns',
-          key: 'align_columns',
-          submenu: {
-            items: [
-              {
-                name: 'Left',
-                key: 'align_columns:1',
-                callback: (key, selection) => { this.align('l', selection[0].start.col, selection[0].end.col) },
-              }, {
-                name: 'Center',
-                key: 'align_columns:2',
-                callback: (key, selection) => { this.align('c', selection[0].start.col, selection[0].end.col) },
-              }, {
-                name: 'Right',
-                key: 'align_columns:3',
-                callback: (key, selection) => { this.align('r', selection[0].start.col, selection[0].end.col) },
-              },
-            ],
-          },
-        },
-      },
-    };
-  }
-
-  show(markdownTable) {
-    this.init(markdownTable);
-    this.setState({ show: true });
-  }
-
-  hide() {
-    this.setState({
-      show: false,
-      isDataImportAreaExpanded: false,
-      isWindowExpanded: false,
-    });
-  }
-
-  /**
-   * Reset table data to initial value
-   *
-   * ## Note ##
-   * It may not return completely to the initial state because of the manualColumnMove operations.
-   * https://github.com/handsontable/handsontable/issues/5591
-   */
-  reset() {
-    this.setState({ markdownTable: this.state.markdownTableOnInit.clone() });
-  }
-
-  cancel() {
-    this.hide();
-  }
-
-  save() {
-    const markdownTable = new MarkdownTable(
-      this.hotTable.hotInstance.getData(),
-      this.markdownTableOption,
-    ).normalizeCells();
-
-    if (this.props.onSave != null) {
-      this.props.onSave(markdownTable);
-    }
-
-    this.hide();
-  }
-
-  /**
-   * An afterLoadData hook
-   *
-   * This performs the following operations.
-   * - clear 'manuallyResizedColumnIndicesSet' for the first loading
-   * - synchronize the handsontable alignment to the markdowntable alignment
-   *
-   * ## Note ##
-   * The afterLoadData hook is called when one of the following states of this component are passed into the setState.
-   *
-   * - markdownTable
-   * - handsontableHeight
-   *
-   * In detail, when the setState method is called with those state passed,
-   * React will start re-render process for the HotTable of this component because the HotTable receives those state values by props.
-   * HotTable#shouldComponentUpdate is called in this re-render process and calls the updateSettings method for the Handsontable instance.
-   * In updateSettings method, the loadData method is called in some case.
-   *  (refs: https://github.com/handsontable/handsontable/blob/6.2.0/src/core.js#L1652-L1657)
-   * The updateSettings method calls in the HotTable always lead to call the loadData method because the HotTable passes data source by settings.data.
-   * After the loadData method is executed, afterLoadData hooks are called.
-   */
-  afterLoadDataHandler(initialLoad) {
-    if (initialLoad) {
-      this.manuallyResizedColumnIndicesSet.clear();
-    }
-
-    this.synchronizeAlignment();
-  }
-
-  beforeColumnResizeHandler(currentColumn) {
-    /*
-     * The following bug disturbs to use 'beforeColumnResizeHandler' to store column index -- 2018.10.23 Yuki Takei
-     * https://github.com/handsontable/handsontable/issues/3328
-     *
-     * At the moment, using 'afterColumnResizeHandler' instead.
-     */
-
-    // store column index
-    // this.manuallyResizedColumnIndicesSet.add(currentColumn);
-  }
-
-  afterColumnResizeHandler(currentColumn) {
-    /*
-     * The following bug disturbs to use 'beforeColumnResizeHandler' to store column index -- 2018.10.23 Yuki Takei
-     * https://github.com/handsontable/handsontable/issues/3328
-     *
-     * At the moment, using 'afterColumnResizeHandler' instead.
-     */
-
-    // store column index
-    this.manuallyResizedColumnIndicesSet.add(currentColumn);
-    // force re-render
-    const hotInstance = this.hotTable.hotInstance;
-    hotInstance.render();
-  }
-
-  modifyColWidthHandler(width, column) {
-    // return original width if the column index exists in 'manuallyResizedColumnIndicesSet'
-    if (this.manuallyResizedColumnIndicesSet.has(column)) {
-      return width;
-    }
-    // return fixed width if first initializing
-    return Math.max(80, Math.min(400, width));
-  }
-
-  beforeColumnMoveHandler(columns, target) {
-    // clear 'manuallyResizedColumnIndicesSet'
-    this.manuallyResizedColumnIndicesSet.clear();
-  }
-
-  /**
-   * An afterColumnMove hook.
-   *
-   * This synchronizes alignment when columns are moved by manualColumnMove
-   */
-  afterColumnMoveHandler(columns, target) {
-    const align = [].concat(this.state.markdownTable.options.align);
-    const removed = align.splice(columns[0], columns.length);
-
-    /*
-     * The following is a description of the algorithm for the alignment synchronization.
-     *
-     * Consider the case where the target is X and the columns are [2,3] and data is as follows.
-     *
-     * 0 1 2 3 4 5 (insert position number)
-     * +-+-+-+-+-+
-     * | | | | | |
-     * +-+-+-+-+-+
-     *  0 1 2 3 4  (column index number)
-     *
-     * At first, remove columns by the splice.
-     *
-     * 0 1 2   4 5
-     * +-+-+   +-+
-     * | | |   | |
-     * +-+-+   +-+
-     *  0 1     4
-     *
-     * Next, insert those columns into a new position.
-     * However the target number is a insert position number before deletion, it may be changed.
-     * These are changed as follows.
-     *
-     * Before:
-     * 0 1 2   4 5
-     * +-+-+   +-+
-     * | | |   | |
-     * +-+-+   +-+
-     *
-     * After:
-     * 0 1 2   2 3
-     * +-+-+   +-+
-     * | | |   | |
-     * +-+-+   +-+
-     *
-     * If X is 0, 1 or 2, that is, lower than columns[0], the target number is not changed.
-     * If X is 4 or 5, that is, higher than columns[columns.length - 1], the target number is modified to the original value minus columns.length.
-     *
-     */
-    let insertPosition = 0;
-    if (target <= columns[0]) {
-      insertPosition = target;
-    }
-    else if (columns[columns.length - 1] < target) {
-      insertPosition = target - columns.length;
-    }
-    align.splice(...[insertPosition, 0].concat(removed));
-
-    this.setState((prevState) => {
-      // change only align info, so share table data to avoid redundant copy
-      const newMarkdownTable = new MarkdownTable(prevState.markdownTable.table, { align });
-      return { markdownTable: newMarkdownTable };
-    }, () => {
-      this.synchronizeAlignment();
-    });
-  }
-
-  /**
-   * change the markdownTable alignment and synchronize the handsontable alignment to it
-   */
-  align(direction, startCol, endCol) {
-    this.setState((prevState) => {
-      // change only align info, so share table data to avoid redundant copy
-      const newMarkdownTable = new MarkdownTable(prevState.markdownTable.table, { align: [].concat(prevState.markdownTable.options.align) });
-      for (let i = startCol; i <= endCol; i++) {
-        newMarkdownTable.options.align[i] = direction;
-      }
-      return { markdownTable: newMarkdownTable };
-    }, () => {
-      this.synchronizeAlignment();
-    });
-  }
-
-  /**
-   * synchronize the handsontable alignment to the markdowntable alignment
-   */
-  synchronizeAlignment() {
-    if (this.hotTable == null) {
-      return;
-    }
-
-    const align = this.state.markdownTable.options.align;
-    const hotInstance = this.hotTable.hotInstance;
-
-    if (hotInstance.isDestroyed === true) {
-      return;
-    }
-
-    for (let i = 0; i < align.length; i++) {
-      for (let j = 0; j < hotInstance.countRows(); j++) {
-        hotInstance.setCellMeta(j, i, 'className', MARKDOWNTABLE_TO_HANDSONTABLE_ALIGNMENT_SYMBOL_MAPPING[align[i]]);
-      }
-    }
-    hotInstance.render();
-  }
-
-  alignButtonHandler(direction) {
-    const selectedRange = this.hotTable.hotInstance.getSelectedRange();
-    if (selectedRange == null) return;
-
-    let startCol;
-    let endCol;
-
-    if (selectedRange[0].from.col < selectedRange[0].to.col) {
-      startCol = selectedRange[0].from.col;
-      endCol = selectedRange[0].to.col;
-    }
-    else {
-      startCol = selectedRange[0].to.col;
-      endCol = selectedRange[0].from.col;
-    }
-
-    this.align(direction, startCol, endCol);
-  }
-
-  toggleDataImportArea() {
-    this.setState({ isDataImportAreaExpanded: !this.state.isDataImportAreaExpanded });
-  }
-
-  /**
-   * Import a markdowntable
-   *
-   * ## Note ##
-   * The manualColumnMove operation affects the column order of imported data.
-   * https://github.com/handsontable/handsontable/issues/5591
-   */
-  importData(markdownTable) {
-    this.init(markdownTable);
-    this.toggleDataImportArea();
-  }
-
-  expandWindow() {
-    this.setState({ isWindowExpanded: true });
-
-    // invoke updateHotTableHeight method with delay
-    // cz. Resizing this.refs.hotTableContainer is completed after a little delay after 'isWindowExpanded' set with 'true'
-    this.expandHotTableHeightWithDebounce();
-  }
-
-  contractWindow() {
-    this.setState({ isWindowExpanded: false, handsontableHeight: DEFAULT_HOT_HEIGHT });
-  }
-
-  /**
-   * Expand the height of the Handsontable
-   *  by updating 'handsontableHeight' state
-   *  according to the height of this.refs.hotTableContainer
-   */
-  expandHotTableHeight() {
-    if (this.state.isWindowExpanded && this.hotTableContainer != null) {
-      const height = this.hotTableContainer.getBoundingClientRect().height;
-      this.setState({ handsontableHeight: height });
-    }
-  }
-
-  get markdownTableOption() {
-    return {
-      align: [].concat(this.state.markdownTable.options.align),
-      pad: this.props.autoFormatMarkdownTable !== false,
-    };
-  }
-
-  renderCloseButton() {
-    return (
-      <button type="button" className="close" onClick={this.cancel} aria-label="Close">
-        <span aria-hidden="true">&times;</span>
-      </button>
-    );
-  }
-
-  render() {
-
-    const buttons = (
-      <span>
-        {/* change order because of `float: right` by '.close' class */}
-        {this.renderCloseButton()}
-        <ExpandOrContractButton
-          isWindowExpanded={this.state.isWindowExpanded}
-          contractWindow={this.contractWindow}
-          expandWindow={this.expandWindow}
-        />
-      </span>
-    );
-
-    return (
-      <Modal
-        isOpen={this.state.show}
-        toggle={this.cancel}
-        backdrop="static"
-        keyboard={false}
-        size="lg"
-        className={`handsontable-modal ${styles['grw-handsontable']}
-          ${this.state.isWindowExpanded && `grw-modal-expanded ${styles['grw-modal-expanded']}`}`}
-      >
-        <ModalHeader tag="h4" toggle={this.cancel} close={buttons} className="bg-primary text-light">
-          Edit Table
-        </ModalHeader>
-        <ModalBody className="p-0 d-flex flex-column">
-          <div className="grw-hot-modal-navbar px-4 py-3 border-bottom">
-            <button
-              type="button"
-              className="mr-4 data-import-button btn btn-secondary"
-              data-toggle="collapse"
-              data-target="#collapseDataImport"
-              aria-expanded={this.state.isDataImportAreaExpanded}
-              onClick={this.toggleDataImportArea}
-            >
-              <span className="mr-3">Data Import</span><i className={this.state.isDataImportAreaExpanded ? 'fa fa-angle-up' : 'fa fa-angle-down'}></i>
-            </button>
-            <div role="group" className="btn-group">
-              <button type="button" className="btn btn-secondary" onClick={() => { this.alignButtonHandler('l') }}>
-                <i className="ti ti-align-left"></i>
-              </button>
-              <button type="button" className="btn btn-secondary" onClick={() => { this.alignButtonHandler('c') }}>
-                <i className="ti ti-align-center"></i>
-              </button>
-              <button type="button" className="btn btn-secondary" onClick={() => { this.alignButtonHandler('r') }}>
-                <i className="ti ti-align-right"></i>
-              </button>
-            </div>
-            <Collapse isOpen={this.state.isDataImportAreaExpanded}>
-              <div className="mt-4">
-                <MarkdownTableDataImportForm onCancel={this.toggleDataImportArea} onImport={this.importData} />
-              </div>
-            </Collapse>
-          </div>
-          <div ref={(c) => { this.hotTableContainer = c }} className="m-4 hot-table-container">
-            <HotTable
-              ref={(c) => { this.hotTable = c }}
-              data={this.state.markdownTable.table}
-              settings={this.handsontableSettings}
-              height={this.state.handsontableHeight}
-              afterLoadData={this.afterLoadDataHandler}
-              modifyColWidth={this.modifyColWidthHandler}
-              beforeColumnMove={this.beforeColumnMoveHandler}
-              beforeColumnResize={this.beforeColumnResizeHandler}
-              afterColumnResize={this.afterColumnResizeHandler}
-              afterColumnMove={this.afterColumnMoveHandler}
-            />
-          </div>
-        </ModalBody>
-        <ModalFooter className="grw-modal-footer">
-          <button type="button" className="btn btn-danger" onClick={this.reset}>Reset</button>
-          <div className="ml-auto">
-            <button type="button" className="mr-2 btn btn-secondary" onClick={this.cancel}>Cancel</button>
-            <button type="button" className="btn btn-primary" onClick={this.save}>Done</button>
-          </div>
-        </ModalFooter>
-      </Modal>
-    );
-  }
-
-  static getDefaultMarkdownTable() {
-    return new MarkdownTable(
-      [
-        ['col1', 'col2', 'col3'],
-        ['', '', ''],
-        ['', '', ''],
-      ],
-      {
-        align: ['', '', ''],
-      },
-    );
-  }
-
-  static getDefaultHandsontableSetting() {
-    return {
-      rowHeaders: true,
-      colHeaders: true,
-      manualRowMove: true,
-      manualRowResize: true,
-      manualColumnMove: true,
-      manualColumnResize: true,
-      selectionMode: 'multiple',
-      outsideClickDeselects: false,
-    };
-  }
-
-}
-
-HandsontableModal.propTypes = {
-  onSave: PropTypes.func,
-  autoFormatMarkdownTable: PropTypes.bool,
-};

+ 27 - 5
packages/app/src/components/PageEditor/HandsontableModal.module.scss

@@ -14,11 +14,33 @@
       text-align: inherit;
     }
   }
-}
 
-// expand .hot-table-container (with flexbox)
-.grw-modal-expanded :global {
-  .hot-table-container {
-    flex: 1;
+  // expand .hot-table-container (with flexbox)
+  .grw-modal-expanded {
+    .hot-table-container {
+      flex: 1;
+    }
+  }
+
+
+  // Prevent handsontable/handsontable #2937 (Manual column resize does not work when handsontable is loaded inside Bootstrap 3.0 Modal)
+  // see https://github.com/handsontable/handsontable/issues/2937#issuecomment-287390111
+  // This issue fixing from Handsontable v 7.0.0
+  // see: https://github.com/handsontable/handsontable/issues/2937#issuecomment-480824024
+  .modal.in .modal-dialog.handsontable-modal {
+    transform: none;
+
+    .data-import-button {
+      position: relative;
+      padding-right: 35px;
+      padding-left: 10px;
+
+      i:before {
+        position: absolute;
+        top: 6px;
+        right: 8px;
+        font-size: 20px;
+      }
+    }
   }
 }

+ 506 - 0
packages/app/src/components/PageEditor/HandsontableModal.tsx

@@ -0,0 +1,506 @@
+import React, { useState, useEffect } from 'react';
+
+import { HotTable } from '@handsontable/react';
+import Handsontable from 'handsontable';
+import { useTranslation } from 'next-i18next';
+import {
+  Collapse,
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+import { debounce } from 'throttle-debounce';
+
+import MarkdownTable from '~/client/models/MarkdownTable';
+import mtu from '~/components/PageEditor/MarkdownTableUtil';
+import { useHandsontableModal } from '~/stores/modal';
+
+import ExpandOrContractButton from '../ExpandOrContractButton';
+
+import { MarkdownTableDataImportForm } from './MarkdownTableDataImportForm';
+
+import styles from './HandsontableModal.module.scss';
+import 'handsontable/dist/handsontable.full.min.css';
+
+const DEFAULT_HOT_HEIGHT = 300;
+const MARKDOWNTABLE_TO_HANDSONTABLE_ALIGNMENT_SYMBOL_MAPPING = {
+  r: 'htRight',
+  c: 'htCenter',
+  l: 'htLeft',
+  '': '',
+};
+
+export const HandsontableModal = (): JSX.Element => {
+
+  const { t } = useTranslation('commons');
+  const { data: handsontableModalData, close: closeHandsontableModal } = useHandsontableModal();
+  const isOpened = handsontableModalData?.isOpened ?? false;
+  const table = handsontableModalData?.table;
+  const autoFormatMarkdownTable = handsontableModalData?.autoFormatMarkdownTable ?? false;
+  const editor = handsontableModalData?.editor;
+
+  const defaultMarkdownTable = () => {
+    return new MarkdownTable(
+      [
+        ['col1', 'col2', 'col3'],
+        ['', '', ''],
+        ['', '', ''],
+      ],
+      {
+        align: ['', '', ''],
+      },
+    );
+  };
+
+  const defaultHandsontableSetting = () => {
+    return {
+      rowHeaders: true,
+      colHeaders: true,
+      manualRowMove: true,
+      manualRowResize: true,
+      manualColumnMove: true,
+      manualColumnResize: true,
+      selectionMode: 'multiple',
+      outsideClickDeselects: false,
+    };
+  };
+
+  // A Set instance that stores column indices which are resized manually.
+  // these columns will NOT be determined the width automatically by 'modifyColWidthHandler'
+  const manuallyResizedColumnIndicesSet = new Set();
+
+  /*
+   * ## Note ##
+   * Currently, this component try to synchronize the cells data and alignment data of state.markdownTable with these of the HotTable.
+   * However, changes made by the following operations are not synchronized.
+   *
+   * 1. move columns: Alignment changes are synchronized but data changes are not.
+   * 2. move rows: Data changes are not synchronized.
+   * 3. insert columns or rows: Data changes are synchronized but alignment changes are not.
+   * 4. delete columns or rows: Data changes are synchronized but alignment changes are not.
+   *
+   * However, all operations are reflected in the data to be saved because the HotTable data is used when the save method is called.
+   */
+  const [hotTable, setHotTable] = useState<HotTable | null>();
+  const [hotTableContainer, setHotTableContainer] = useState<HTMLDivElement | null>();
+  const [isDataImportAreaExpanded, setIsDataImportAreaExpanded] = useState<boolean>(false);
+  const [isWindowExpanded, setIsWindowExpanded] = useState<boolean>(false);
+  const [markdownTable, setMarkdownTable] = useState<MarkdownTable>(defaultMarkdownTable);
+  const [markdownTableOnInit, setMarkdownTableOnInit] = useState<MarkdownTable>(defaultMarkdownTable);
+  const [handsontableHeight, setHandsontableHeight] = useState<number>(DEFAULT_HOT_HEIGHT);
+
+  useEffect(() => {
+    const initTableInstance = table == null ? defaultMarkdownTable : table.clone();
+    setMarkdownTable(table ?? defaultMarkdownTable);
+    setMarkdownTableOnInit(initTableInstance);
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [isOpened]);
+
+  const markdownTableOption = {
+    get latest() {
+      return {
+        align: [].concat(markdownTable.options.align),
+        pad: autoFormatMarkdownTable !== false,
+      };
+    },
+  };
+
+  /**
+   * Reset table data to initial value
+   *
+   * ## Note ##
+   * It may not return completely to the initial state because of the manualColumnMove operations.
+   * https://github.com/handsontable/handsontable/issues/5591
+   */
+  const reset = () => {
+    setMarkdownTable(markdownTableOnInit.clone());
+  };
+
+  const cancel = () => {
+    closeHandsontableModal();
+    setIsDataImportAreaExpanded(false);
+    setIsWindowExpanded(false);
+  };
+
+  const save = () => {
+    if (hotTable == null || editor == null) {
+      return;
+    }
+
+    const newMarkdownTable = new MarkdownTable(
+      hotTable.hotInstance.getData(),
+      markdownTableOption.latest,
+    ).normalizeCells();
+
+    mtu.replaceFocusedMarkdownTableWithEditor(editor, newMarkdownTable);
+
+    cancel();
+  };
+
+  const beforeColumnResizeHandler = (currentColumn) => {
+    /*
+     * The following bug disturbs to use 'beforeColumnResizeHandler' to store column index -- 2018.10.23 Yuki Takei
+     * https://github.com/handsontable/handsontable/issues/3328
+     *
+     * At the moment, using 'afterColumnResizeHandler' instead.
+     */
+
+    // store column index
+    // this.manuallyResizedColumnIndicesSet.add(currentColumn);
+  };
+
+  const afterColumnResizeHandler = (currentColumn) => {
+    if (hotTable == null) {
+      return;
+    }
+
+    /*
+     * The following bug disturbs to use 'beforeColumnResizeHandler' to store column index -- 2018.10.23 Yuki Takei
+     * https://github.com/handsontable/handsontable/issues/3328
+     *
+     * At the moment, using 'afterColumnResizeHandler' instead.
+     */
+
+    // store column index
+    manuallyResizedColumnIndicesSet.add(currentColumn);
+    // force re-render
+    const hotInstance = hotTable.hotInstance;
+    hotInstance.render();
+  };
+
+  const modifyColWidthHandler = (width, column) => {
+    // return original width if the column index exists in 'manuallyResizedColumnIndicesSet'
+    if (manuallyResizedColumnIndicesSet.has(column)) {
+      return width;
+    }
+    // return fixed width if first initializing
+    return Math.max(80, Math.min(400, width));
+  };
+
+  const beforeColumnMoveHandler = (columns, target) => {
+    // clear 'manuallyResizedColumnIndicesSet'
+    manuallyResizedColumnIndicesSet.clear();
+  };
+
+  /**
+   * synchronize the handsontable alignment to the markdowntable alignment
+   */
+  const synchronizeAlignment = () => {
+    if (hotTable == null) {
+      return;
+    }
+
+    const align = markdownTable.options.align;
+    const hotInstance = hotTable.hotInstance;
+
+    if (hotInstance.isDestroyed === true || align == null) {
+      return;
+    }
+
+    for (let i = 0; i < align.length; i++) {
+      for (let j = 0; j < hotInstance.countRows(); j++) {
+        hotInstance.setCellMeta(j, i, 'className', MARKDOWNTABLE_TO_HANDSONTABLE_ALIGNMENT_SYMBOL_MAPPING[align[i]]);
+      }
+    }
+    hotInstance.render();
+  };
+
+  /**
+   * An afterLoadData hook
+   *
+   * This performs the following operations.
+   * - clear 'manuallyResizedColumnIndicesSet' for the first loading
+   * - synchronize the handsontable alignment to the markdowntable alignment
+   *
+   * ## Note ##
+   * The afterLoadData hook is called when one of the following states of this component are passed into the setState.
+   *
+   * - markdownTable
+   * - handsontableHeight
+   *
+   * In detail, when the setState method is called with those state passed,
+   * React will start re-render process for the HotTable of this component because the HotTable receives those state values by props.
+   * HotTable#shouldComponentUpdate is called in this re-render process and calls the updateSettings method for the Handsontable instance.
+   * In updateSettings method, the loadData method is called in some case.
+   *  (refs: https://github.com/handsontable/handsontable/blob/6.2.0/src/core.js#L1652-L1657)
+   * The updateSettings method calls in the HotTable always lead to call the loadData method because the HotTable passes data source by settings.data.
+   * After the loadData method is executed, afterLoadData hooks are called.
+   */
+  const afterLoadDataHandler = (initialLoad: boolean) => {
+    if (initialLoad) {
+      manuallyResizedColumnIndicesSet.clear();
+    }
+
+    synchronizeAlignment();
+  };
+
+  /**
+   * An afterColumnMove hook.
+   *
+   * This synchronizes alignment when columns are moved by manualColumnMove
+   */
+  // TODO: colums type is number[]
+  const afterColumnMoveHandler = (columns: any, target: number) => {
+    const align = [].concat(markdownTable.options.align);
+    const removed = align.splice(columns[0], columns.length);
+
+    /*
+      * The following is a description of the algorithm for the alignment synchronization.
+      *
+      * Consider the case where the target is X and the columns are [2,3] and data is as follows.
+      *
+      * 0 1 2 3 4 5 (insert position number)
+      * +-+-+-+-+-+
+      * | | | | | |
+      * +-+-+-+-+-+
+      *  0 1 2 3 4  (column index number)
+      *
+      * At first, remove columns by the splice.
+      *
+      * 0 1 2   4 5
+      * +-+-+   +-+
+      * | | |   | |
+      * +-+-+   +-+
+      *  0 1     4
+      *
+      * Next, insert those columns into a new position.
+      * However the target number is a insert position number before deletion, it may be changed.
+      * These are changed as follows.
+      *
+      * Before:
+      * 0 1 2   4 5
+      * +-+-+   +-+
+      * | | |   | |
+      * +-+-+   +-+
+      *
+      * After:
+      * 0 1 2   2 3
+      * +-+-+   +-+
+      * | | |   | |
+      * +-+-+   +-+
+      *
+      * If X is 0, 1 or 2, that is, lower than columns[0], the target number is not changed.
+      * If X is 4 or 5, that is, higher than columns[columns.length - 1], the target number is modified to the original value minus columns.length.
+      *
+      */
+    let insertPosition = 0;
+    if (target <= columns[0]) {
+      insertPosition = target;
+    }
+    else if (columns[columns.length - 1] < target) {
+      insertPosition = target - columns.length;
+    }
+
+    for (let i = 0; i < removed.length; i++) {
+      align.splice(insertPosition + i, 0, removed[i]);
+    }
+
+    setMarkdownTable((prevMarkdownTable) => {
+      // change only align info, so share table data to avoid redundant copy
+      const newMarkdownTable = new MarkdownTable(prevMarkdownTable.table, { align });
+      return newMarkdownTable;
+    });
+
+    synchronizeAlignment();
+  };
+
+  /**
+   * change the markdownTable alignment and synchronize the handsontable alignment to it
+   */
+  const align = (direction: string, startCol: number, endCol: number) => {
+    setMarkdownTable((prevMarkdownTable) => {
+      // change only align info, so share table data to avoid redundant copy
+      const newMarkdownTable = new MarkdownTable(prevMarkdownTable.table, { align: [].concat(prevMarkdownTable.options.align) });
+      for (let i = startCol; i <= endCol; i++) {
+        newMarkdownTable.options.align[i] = direction;
+      }
+      return newMarkdownTable;
+    });
+
+    synchronizeAlignment();
+  };
+
+  const alignButtonHandler = (direction: string) => {
+    if (hotTable == null) {
+      return;
+    }
+
+    const selectedRange = hotTable.hotInstance.getSelectedRange();
+    if (selectedRange == null) return;
+
+    const startCol = selectedRange[0].from.col < selectedRange[0].to.col ? selectedRange[0].from.col : selectedRange[0].to.col;
+    const endCol = selectedRange[0].from.col < selectedRange[0].to.col ? selectedRange[0].to.col : selectedRange[0].from.col;
+
+    align(direction, startCol, endCol);
+  };
+
+  const toggleDataImportArea = () => {
+    setIsDataImportAreaExpanded(!isDataImportAreaExpanded);
+  };
+
+  const init = (markdownTable: MarkdownTable) => {
+    const initMarkdownTable = markdownTable || defaultMarkdownTable;
+    setMarkdownTableOnInit(initMarkdownTable);
+    setMarkdownTable(initMarkdownTable.clone());
+    manuallyResizedColumnIndicesSet.clear();
+  };
+
+  /**
+   * Import a markdowntable
+   *
+   * ## Note ##
+   * The manualColumnMove operation affects the column order of imported data.
+   * https://github.com/handsontable/handsontable/issues/5591
+   */
+  const importData = (markdownTable: MarkdownTable) => {
+    init(markdownTable);
+    toggleDataImportArea();
+  };
+
+  /**
+   * Expand the height of the Handsontable
+   *  by updating 'handsontableHeight' state
+   *  according to the height of this.refs.hotTableContainer
+   */
+  const expandHotTableHeight = () => {
+    if (isWindowExpanded && hotTableContainer != null) {
+      const height = hotTableContainer.getBoundingClientRect().height;
+      setHandsontableHeight(height);
+    }
+  };
+
+  const expandWindow = () => {
+    setIsWindowExpanded(true);
+
+    // create debounced method for expanding HotTable
+    // invoke updateHotTableHeight method with delay
+    // cz. Resizing this.refs.hotTableContainer is completed after a little delay after 'isWindowExpanded' set with 'true'
+    debounce(100, expandHotTableHeight);
+  };
+
+  const contractWindow = () => {
+    setIsWindowExpanded(false);
+    setHandsontableHeight(DEFAULT_HOT_HEIGHT);
+  };
+
+  const createCustomizedContextMenu = () => {
+    return {
+      items: {
+        row_above: {},
+        row_below: {},
+        col_left: {},
+        col_right: {},
+        separator1: '---------',
+        remove_row: {},
+        remove_col: {},
+        separator2: '---------',
+        custom_alignment: {
+          name: 'Align columns',
+          key: 'align_columns',
+          submenu: {
+            items: [
+              {
+                name: 'Left',
+                key: 'align_columns:1',
+                callback: (key, selection) => { align('l', selection[0].start.col, selection[0].end.col) },
+              }, {
+                name: 'Center',
+                key: 'align_columns:2',
+                callback: (key, selection) => { align('c', selection[0].start.col, selection[0].end.col) },
+              }, {
+                name: 'Right',
+                key: 'align_columns:3',
+                callback: (key, selection) => { align('r', selection[0].start.col, selection[0].end.col) },
+              },
+            ],
+          },
+        },
+      },
+    };
+  };
+
+  // generate setting object for HotTable instance
+  const handsontableSettings = Object.assign(defaultHandsontableSetting(), {
+    contextMenu: createCustomizedContextMenu(),
+  });
+
+  const closeButton = (
+    <span>
+      {/* change order because of `float: right` by '.close' class */}
+      <button type="button" className="close" onClick={cancel} aria-label="Close">
+        <span aria-hidden="true">&times;</span>
+      </button>
+      <ExpandOrContractButton
+        isWindowExpanded={isWindowExpanded}
+        contractWindow={contractWindow}
+        expandWindow={expandWindow}
+      />
+    </span>
+  );
+
+  return (
+    <Modal
+      isOpen={isOpened}
+      toggle={cancel}
+      backdrop="static"
+      keyboard={false}
+      size="lg"
+      wrapClassName={`${styles['grw-handsontable']}`}
+      className={`handsontable-modal ${isWindowExpanded && 'grw-modal-expanded'}`}
+    >
+      <ModalHeader tag="h4" toggle={cancel} close={closeButton} className="bg-primary text-light">
+        {t('handsontable_modal.title')}
+      </ModalHeader>
+      <ModalBody className="p-0 d-flex flex-column">
+        <div className="grw-hot-modal-navbar px-4 py-3 border-bottom">
+          <button
+            type="button"
+            className="mr-4 data-import-button btn btn-secondary"
+            data-toggle="collapse"
+            data-target="#collapseDataImport"
+            aria-expanded={isDataImportAreaExpanded}
+            onClick={toggleDataImportArea}
+          >
+            <span className="mr-3">{t('handsontable_modal.data_import')}</span>
+            <i className={isDataImportAreaExpanded ? 'fa fa-angle-up' : 'fa fa-angle-down'}></i>
+          </button>
+          <div role="group" className="btn-group">
+            <button type="button" className="btn btn-secondary" onClick={() => { alignButtonHandler('l') }}>
+              <i className="ti ti-align-left"></i>
+            </button>
+            <button type="button" className="btn btn-secondary" onClick={() => { alignButtonHandler('c') }}>
+              <i className="ti ti-align-center"></i>
+            </button>
+            <button type="button" className="btn btn-secondary" onClick={() => { alignButtonHandler('r') }}>
+              <i className="ti ti-align-right"></i>
+            </button>
+          </div>
+          <Collapse isOpen={isDataImportAreaExpanded}>
+            <div className="mt-4">
+              <MarkdownTableDataImportForm onCancel={toggleDataImportArea} onImport={importData} />
+            </div>
+          </Collapse>
+        </div>
+        <div ref={c => setHotTableContainer(c)} className="m-4 hot-table-container">
+          <HotTable
+            ref={c => setHotTable(c)}
+            data={markdownTable.table}
+            settings={handsontableSettings as Handsontable.DefaultSettings}
+            height={handsontableHeight}
+            afterLoadData={afterLoadDataHandler}
+            modifyColWidth={modifyColWidthHandler}
+            beforeColumnMove={beforeColumnMoveHandler}
+            beforeColumnResize={beforeColumnResizeHandler}
+            afterColumnResize={afterColumnResizeHandler}
+            afterColumnMove={afterColumnMoveHandler}
+          />
+        </div>
+      </ModalBody>
+      <ModalFooter className="grw-modal-footer">
+        <button type="button" className="btn btn-danger" onClick={reset}>{t('handsontable_modal.reset')}</button>
+        <div className="ml-auto">
+          <button type="button" className="mr-2 btn btn-secondary" onClick={cancel}>{t('handsontable_modal.cancel')}</button>
+          <button type="button" className="btn btn-primary" onClick={save}>{t('handsontable_modal.done')}</button>
+        </div>
+      </ModalFooter>
+    </Modal>
+  );
+};

+ 0 - 103
packages/app/src/components/PageEditor/MarkdownTableDataImportForm.jsx

@@ -1,103 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import {
-  Button,
-  Collapse,
-} from 'reactstrap';
-
-import MarkdownTable from '~/client/models/MarkdownTable';
-
-export default class MarkdownTableDataImportForm extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      dataFormat: 'csv',
-      data: '',
-      parserErrorMessage: null,
-    };
-
-    this.importButtonHandler = this.importButtonHandler.bind(this);
-  }
-
-  importButtonHandler() {
-    try {
-      const markdownTable = this.convertFormDataToMarkdownTable();
-      this.props.onImport(markdownTable);
-      this.setState({ parserErrorMessage: null });
-    }
-    catch (e) {
-      this.setState({ parserErrorMessage: e.message });
-    }
-  }
-
-  convertFormDataToMarkdownTable() {
-    let result;
-    switch (this.state.dataFormat) {
-      case 'csv':
-        result = MarkdownTable.fromDSV(this.state.data, ',');
-        break;
-      case 'tsv':
-        result = MarkdownTable.fromDSV(this.state.data, '\t');
-        break;
-      case 'html':
-        result = MarkdownTable.fromHTMLTableTag(this.state.data);
-        break;
-    }
-    return result.normalizeCells();
-  }
-
-  render() {
-    return (
-      <form className="data-import-form">
-        <div className="form-group">
-          <label htmlFor="data-import-form-type-select">Select Data Format</label>
-          <select
-            id="data-import-form-type-select"
-            className="form-control"
-            placeholder="select"
-            value={this.state.dataFormat}
-            onChange={(e) => { return this.setState({ dataFormat: e.target.value }) }}
-          >
-            <option value="csv">CSV</option>
-            <option value="tsv">TSV</option>
-            <option value="html">HTML</option>
-          </select>
-        </div>
-        <div className="form-group">
-          <label htmlFor="data-import-form-type-textarea">Import Data</label>
-          <textarea
-            id="data-import-form-type-textarea"
-            className="form-control"
-            placeholder="Paste table data"
-            rows="8"
-            onChange={(e) => { return this.setState({ data: e.target.value }) }}
-          />
-        </div>
-        <Collapse isOpen={this.state.parserErrorMessage != null}>
-          <div className="form-group">
-            <label htmlFor="data-import-form-type-textarea-alert">Parse Error</label>
-            <textarea
-              id="data-import-form-type-textarea-alert"
-              className="form-control"
-              rows="4"
-              value={this.state.parserErrorMessage || ''}
-              readOnly
-            />
-          </div>
-        </Collapse>
-        <div className="d-flex justify-content-end">
-          <Button color="secondary mr-2" onClick={this.props.onCancel}>Cancel</Button>
-          <Button color="primary" onClick={this.importButtonHandler}>Import</Button>
-        </div>
-      </form>
-    );
-  }
-
-}
-
-MarkdownTableDataImportForm.propTypes = {
-  onCancel: PropTypes.func,
-  onImport: PropTypes.func,
-};

+ 98 - 0
packages/app/src/components/PageEditor/MarkdownTableDataImportForm.tsx

@@ -0,0 +1,98 @@
+import React, { useState } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import {
+  Button,
+  Collapse,
+} from 'reactstrap';
+
+import MarkdownTable from '~/client/models/MarkdownTable';
+
+type MarkdownTableDataImportFormProps = {
+  onCancel: () => void,
+  onImport: (table: MarkdownTable) => void,
+}
+
+export const MarkdownTableDataImportForm = (props: MarkdownTableDataImportFormProps): JSX.Element => {
+
+  const { onCancel, onImport } = props;
+
+  const { t } = useTranslation('commons', { keyPrefix: 'handsontable_modal.data_import_form' });
+
+  const [dataFormat, setDataFormat] = useState<string>('csv');
+  const [data, setData] = useState<string>('');
+  const [parserErrorMessage, setParserErrorMessage] = useState(null);
+
+  const convertFormDataToMarkdownTable = () => {
+    let result;
+    switch (dataFormat) {
+      case 'csv':
+        result = MarkdownTable.fromDSV(data, ',');
+        break;
+      case 'tsv':
+        result = MarkdownTable.fromDSV(data, '\t');
+        break;
+      case 'html':
+        result = MarkdownTable.fromHTMLTableTag(data);
+        break;
+    }
+    return result.normalizeCells();
+  };
+
+  const importButtonHandler = () => {
+    try {
+      const markdownTable = convertFormDataToMarkdownTable();
+      onImport(markdownTable);
+      setParserErrorMessage(null);
+    }
+    catch (e) {
+      setParserErrorMessage(e.message);
+    }
+  };
+
+  return (
+    <form className="data-import-form">
+      <div className="form-group">
+        <label htmlFor="data-import-form-type-select">{t('select_data_format')}</label>
+        <select
+          id="data-import-form-type-select"
+          className="form-control"
+          placeholder="select"
+          value={dataFormat}
+          onChange={(e) => { return setDataFormat(e.target.value) }}
+        >
+          <option value="csv">CSV</option>
+          <option value="tsv">TSV</option>
+          <option value="html">HTML</option>
+        </select>
+      </div>
+      <div className="form-group">
+        <label htmlFor="data-import-form-type-textarea">{t('import_data')}</label>
+        <textarea
+          id="data-import-form-type-textarea"
+          className="form-control"
+          placeholder={t('paste_table_data')}
+          rows={8}
+          onChange={(e) => { return setData(e.target.value) }}
+        />
+      </div>
+      <Collapse isOpen={parserErrorMessage != null}>
+        <div className="form-group">
+          <label htmlFor="data-import-form-type-textarea-alert">{t('parse_error')}</label>
+          <textarea
+            id="data-import-form-type-textarea-alert"
+            className="form-control"
+            rows={4}
+            value={parserErrorMessage || ''}
+            readOnly
+          />
+        </div>
+      </Collapse>
+      <div className="d-flex justify-content-end">
+        <Button color="secondary mr-2" onClick={onCancel}>{t('cancel')}</Button>
+        <Button color="primary" onClick={importButtonHandler}>{t('import')}</Button>
+      </div>
+    </form>
+  );
+
+};

+ 8 - 2
packages/app/src/components/PageHistory.tsx

@@ -12,13 +12,13 @@ import { RevisionComparer } from './RevisionComparer/RevisionComparer';
 
 const logger = loggerFactory('growi:PageHistory');
 
-export const PageHistory = (): JSX.Element => {
+export const PageHistory: React.FC<{ onClose: () => void }> = ({ onClose }) => {
 
   const [activePage, setActivePage] = useState(1);
 
   const { data: currentPageId } = useCurrentPageId();
 
-  const { data: revisionsData } = useSWRxPageRevisions(activePage, 10, currentPageId);
+  const { data: revisionsData, mutate: mutatePageRevisions } = useSWRxPageRevisions(activePage, 10, currentPageId);
 
   const [sourceRevision, setSourceRevision] = useState<IRevisionHasPageId>();
   const [targetRevision, setTargetRevision] = useState<IRevisionHasPageId>();
@@ -30,6 +30,10 @@ export const PageHistory = (): JSX.Element => {
     }
   }, [revisionsData]);
 
+  useEffect(() => {
+    mutatePageRevisions();
+  });
+
   const pagingLimit = 10;
 
   if (revisionsData == null || sourceRevision == null || targetRevision == null || currentPageId == null) {
@@ -61,6 +65,7 @@ export const PageHistory = (): JSX.Element => {
         targetRevision={targetRevision}
         onChangeSourceInvoked={setSourceRevision}
         onChangeTargetInvoked={setTargetRevision}
+        onClose={onClose}
       />
       <div className="my-3">
         {pager()}
@@ -69,6 +74,7 @@ export const PageHistory = (): JSX.Element => {
         sourceRevision={sourceRevision}
         targetRevision={targetRevision}
         currentPageId={currentPageId}
+        onClose={onClose}
       />
     </div>
   );

+ 6 - 4
packages/app/src/components/PageHistory/PageRevisionTable.tsx

@@ -14,13 +14,14 @@ type PageRevisionTAble = {
   targetRevision: IRevisionHasId,
   onChangeSourceInvoked: React.Dispatch<React.SetStateAction<IRevisionHasId | undefined>>,
   onChangeTargetInvoked: React.Dispatch<React.SetStateAction<IRevisionHasId | undefined>>,
+  onClose: () => void,
 }
 
 export const PageRevisionTable = (props: PageRevisionTAble): JSX.Element => {
   const { t } = useTranslation();
 
   const {
-    revisions, pagingLimit, sourceRevision, targetRevision, onChangeSourceInvoked, onChangeTargetInvoked,
+    revisions, pagingLimit, sourceRevision, targetRevision, onChangeSourceInvoked, onChangeTargetInvoked, onClose,
   } = props;
 
   const revisionCount = revisions.length;
@@ -51,6 +52,7 @@ export const PageRevisionTable = (props: PageRevisionTAble): JSX.Element => {
               isLatestRevision={revision === latestRevision}
               hasDiff={hasDiff}
               key={`revision-history-rev-${revisionId}`}
+              onClose={onClose}
             />
             {hasDiff && (
               <div className="ml-md-3 mt-auto">
@@ -132,9 +134,9 @@ export const PageRevisionTable = (props: PageRevisionTAble): JSX.Element => {
     <table className={`${styles['revision-history-table']} table revision-history-table`}>
       <thead>
         <tr className="d-flex">
-          <th className="col">{ t('page_history.revision') }</th>
-          <th className="col-1">{ t('page_history.comparing_source') }</th>
-          <th className="col-2">{ t('page_history.comparing_target') }</th>
+          <th className="col">{t('page_history.revision')}</th>
+          <th className="col-1">{t('page_history.comparing_source')}</th>
+          <th className="col-2">{t('page_history.comparing_target')}</th>
         </tr>
       </thead>
       <tbody className="overflow-auto d-block">

+ 11 - 5
packages/app/src/components/PageHistory/Revision.tsx

@@ -3,6 +3,7 @@ import React from 'react';
 import { IRevisionHasId } from '@growi/core';
 import { UserPicture } from '@growi/ui';
 import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
 
 import UserDate from '../User/UserDate';
 import { Username } from '../User/Username';
@@ -13,12 +14,15 @@ type RevisionProps = {
   revision: IRevisionHasId,
   isLatestRevision: boolean,
   hasDiff: boolean,
+  onClose: () => void,
 }
 
 export const Revision = (props: RevisionProps): JSX.Element => {
   const { t } = useTranslation();
 
-  const { revision, isLatestRevision, hasDiff } = props;
+  const {
+    revision, isLatestRevision, hasDiff, onClose,
+  } = props;
 
   const renderSimplifiedNodiff = (revision: IRevisionHasId) => {
 
@@ -34,7 +38,7 @@ export const Revision = (props: RevisionProps): JSX.Element => {
         </div>
         <div className="ml-3">
           <span className="text-muted small">
-            <UserDate dateTime={revision.createdAt} /> ({ t('No diff') })
+            <UserDate dateTime={revision.createdAt} /> {t('No diff')}
           </span>
         </div>
       </div>
@@ -60,9 +64,11 @@ export const Revision = (props: RevisionProps): JSX.Element => {
           <div className="mb-1">
             <UserDate dateTime={revision.createdAt} />
             <br className="d-xl-none d-block" />
-            <a className="ml-xl-3" href={`?revisionId=${revision._id}`}>
-              <i className="icon-login"></i> { t('Go to this version') }
-            </a>
+            <Link href={`?revisionId=${revision._id}`} prefetch={false}>
+              <a className="ml-xl-3" onClick={onClose}>
+                <i className="icon-login"></i> {t('Go to this version')}
+              </a>
+            </Link>
           </div>
         </div>
       </div>

+ 15 - 8
packages/app/src/components/PageHistory/RevisionDiff.tsx

@@ -4,6 +4,7 @@ import { IRevisionHasPageId } from '@growi/core';
 import { createPatch } from 'diff';
 import { html, Diff2HtmlConfig } from 'diff2html';
 import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
 
 import UserDate from '../User/UserDate';
 
@@ -15,12 +16,15 @@ type RevisioinDiffProps = {
   currentRevision: IRevisionHasPageId,
   previousRevision: IRevisionHasPageId,
   revisionDiffOpened: boolean,
+  onClose: () => void,
 }
 
 export const RevisionDiff = (props: RevisioinDiffProps): JSX.Element => {
   const { t } = useTranslation();
 
-  const { currentRevision, previousRevision, revisionDiffOpened } = props;
+  const {
+    currentRevision, previousRevision, revisionDiffOpened, onClose,
+  } = props;
 
   const previousText = (currentRevision._id === previousRevision._id) ? '' : previousRevision.body;
 
@@ -46,16 +50,19 @@ export const RevisionDiff = (props: RevisioinDiffProps): JSX.Element => {
           <div className="row">
             <div className="col comparison-source-wrapper pt-1 px-0">
               <span className="comparison-source pr-3">{t('page_history.comparing_source')}</span><UserDate dateTime={previousRevision.createdAt} />
-              <a href={`?revisionId=${previousRevision._id}`} className="ml-3">
-                <i className="icon-login"></i>
-              </a>
-
+              <Link href={`?revisionId=${previousRevision._id}`}>
+                <a className="ml-3" onClick={onClose}>
+                  <i className="icon-login"></i>
+                </a>
+              </Link>
             </div>
             <div className="col comparison-target-wrapper pt-1">
               <span className="comparison-target pr-3">{t('page_history.comparing_target')}</span><UserDate dateTime={currentRevision.createdAt} />
-              <a href={`?revisionId=${currentRevision._id}`} className="ml-3">
-                <i className="icon-login"></i>
-              </a>
+              <Link href={`?revisionId=${currentRevision._id}`}>
+                <a className="ml-3" onClick={onClose}>
+                  <i className="icon-login"></i>
+                </a>
+              </Link>
             </div>
           </div>
         </div>

+ 3 - 1
packages/app/src/components/RevisionComparer/RevisionComparer.tsx

@@ -26,13 +26,14 @@ type RevisionComparerProps = {
   sourceRevision: IRevisionHasPageId
   targetRevision: IRevisionHasPageId
   currentPageId?: string
+  onClose: () => void
 }
 
 export const RevisionComparer = (props: RevisionComparerProps): JSX.Element => {
   const { t } = useTranslation(['translation', 'commons']);
 
   const {
-    sourceRevision, targetRevision, currentPageId,
+    sourceRevision, targetRevision, currentPageId, onClose,
   } = props;
 
   const { data: currentPagePath } = useCurrentPagePath();
@@ -104,6 +105,7 @@ export const RevisionComparer = (props: RevisionComparerProps): JSX.Element => {
               revisionDiffOpened
               previousRevision={sourceRevision}
               currentRevision={targetRevision}
+              onClose={onClose}
             />
           )
         }

+ 10 - 2
packages/app/src/pages/[[...path]].page.tsx

@@ -57,7 +57,7 @@ import DisplaySwitcher from '../components/Page/DisplaySwitcher';
 // import PageStatusAlert from '../client/js/components/PageStatusAlert';
 import {
   useCurrentUser,
-  useIsLatestRevision,
+  useIsLatestRevision, useCurrentRevisionId,
   useIsForbidden, useIsNotFound, useIsSharedUser,
   useIsEnabledStaleNotification, useIsIdenticalPath,
   useIsSearchServiceConfigured, useIsSearchServiceReachable, useDisableLinkSharing,
@@ -80,6 +80,7 @@ const UnsavedAlertDialog = dynamic(() => import('../components/UnsavedAlertDialo
 const GrowiSubNavigationSwitcher = dynamic(() => import('../components/Navbar/GrowiSubNavigationSwitcher'), { ssr: false });
 const UsersHomePageFooter = dynamic<UsersHomePageFooterProps>(() => import('../components/UsersHomePageFooter')
   .then(mod => mod.UsersHomePageFooter), { ssr: false });
+const HandsontableModal = dynamic(() => import('../components/PageEditor/HandsontableModal').then(mod => mod.HandsontableModal), { ssr: false });
 
 const logger = loggerFactory('growi:pages:all');
 
@@ -134,7 +135,8 @@ type Props = CommonProps & {
   redirectFrom?: string;
 
   // shareLinkId?: string;
-  isLatestRevision?: boolean
+  isLatestRevision?: boolean,
+  currentRevisionId?: string,
 
   isIdenticalPathPage?: boolean,
   isForbidden: boolean,
@@ -241,6 +243,7 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   useCurrentPageId(pageId ?? null);
   // useIsNotCreatable(props.isForbidden || !isCreatablePage(pagePath)); // TODO: need to include props.isIdentical
   useCurrentPathname(props.currentPathname);
+  useCurrentRevisionId(props.currentRevisionId);
 
   const { data: currentPage } = useSWRxCurrentPage(undefined, pageWithMeta?.data ?? null); // store initial data
   useEditingMarkdown(pageWithMeta?.data.revision?.body ?? '');
@@ -339,6 +342,7 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
 
           <UnsavedAlertDialog />
           <DescendantsPageListModal />
+          <HandsontableModal />
           {shouldRenderPutbackPageModal && <PutbackPageModal />}
         </div>
       </BasicLayout>
@@ -413,6 +417,10 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
     props.isLatestRevision = page.isLatestRevision();
   }
 
+  if (typeof revisionId === 'string' || typeof revisionId === 'undefined') {
+    props.currentRevisionId = props.isLatestRevision && page.latestRevision != null ? page.latestRevision.toString() : revisionId;
+  }
+
   props.pageWithMeta = pageWithMeta;
 }
 

+ 2 - 0
packages/app/src/pages/admin/[...path].page.tsx

@@ -4,6 +4,7 @@ import {
 import dynamic from 'next/dynamic';
 
 import { CommonProps } from '~/pages/utils/commons';
+import { useCurrentUser } from '~/stores/context';
 import { useIsMaintenanceMode } from '~/stores/maintenanceMode';
 
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
@@ -14,6 +15,7 @@ const AdminNotFoundPage = dynamic(() => import('~/components/Admin/NotFoundPage'
 
 const AdminAppPage: NextPage<CommonProps> = (props) => {
   useIsMaintenanceMode(props.isMaintenanceMode);
+  useCurrentUser(props.currentUser ?? null);
 
 
   return (

+ 4 - 0
packages/app/src/pages/admin/app.page.tsx

@@ -6,12 +6,15 @@ import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import { Container, Provider } from 'unstated';
 
+
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { useCurrentUser } from '~/stores/context';
 import { useIsMaintenanceMode } from '~/stores/maintenanceMode';
 
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
 
+
 const AdminLayout = dynamic(() => import('~/components/Layout/AdminLayout'), { ssr: false });
 const AppSettingsPageContents = dynamic(() => import('~/components/Admin/App/AppSettingsPageContents'), { ssr: false });
 
@@ -19,6 +22,7 @@ const AppSettingsPageContents = dynamic(() => import('~/components/Admin/App/App
 const AdminAppPage: NextPage<CommonProps> = (props) => {
   const { t } = useTranslation('commons');
   useIsMaintenanceMode(props.isMaintenanceMode);
+  useCurrentUser(props.currentUser ?? null);
 
   const title = t('headers.app_settings');
   const injectableContainers: Container<any>[] = [];

+ 2 - 1
packages/app/src/pages/admin/audit-log.page.tsx

@@ -7,7 +7,7 @@ import dynamic from 'next/dynamic';
 import { SupportedActionType } from '~/interfaces/activity';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
-import { useAuditLogEnabled, useAuditLogAvailableActions } from '~/stores/context';
+import { useCurrentUser, useAuditLogEnabled, useAuditLogAvailableActions } from '~/stores/context';
 
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
 
@@ -26,6 +26,7 @@ const AdminAuditLogPage: NextPage<Props> = (props) => {
   const { t } = useTranslation('admin');
   useAuditLogEnabled(props.auditLogEnabled);
   useAuditLogAvailableActions(props.auditLogAvailableActions);
+  useCurrentUser(props.currentUser ?? null);
 
   const title = t('audit_log_management.audit_log');
 

+ 2 - 1
packages/app/src/pages/admin/customize.page.tsx

@@ -9,7 +9,7 @@ import { Container, Provider } from 'unstated';
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
-import { useCustomizeTitle } from '~/stores/context';
+import { useCustomizeTitle, useCurrentUser } from '~/stores/context';
 
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
 
@@ -25,6 +25,7 @@ type Props = CommonProps & {
 const AdminCustomizeSettingsPage: NextPage<Props> = (props) => {
   const { t } = useTranslation('admin');
   useCustomizeTitle(props.customizeTitle);
+  useCurrentUser(props.currentUser ?? null);
 
   const title = t('customize_settings.customize_settings');
   const injectableContainers: Container<any>[] = [];

+ 2 - 0
packages/app/src/pages/admin/export.page.tsx

@@ -8,6 +8,7 @@ import { Container, Provider } from 'unstated';
 
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { useCurrentUser } from '~/stores/context';
 
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
 
@@ -17,6 +18,7 @@ const ExportArchiveDataPage = dynamic(() => import('~/components/Admin/ExportArc
 
 const AdminExportDataArchivePage: NextPage<CommonProps> = (props) => {
   const { t } = useTranslation('admin');
+  useCurrentUser(props.currentUser ?? null);
 
   const title = t('export_management.export_archive_data');
   const injectableContainers: Container<any>[] = [];

+ 3 - 0
packages/app/src/pages/admin/global-notification/[globalNotificationId].page.tsx

@@ -9,9 +9,11 @@ import dynamic from 'next/dynamic';
 import { useRouter } from 'next/router';
 import { Container, Provider } from 'unstated';
 
+
 import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
 import { toastError } from '~/client/util/apiNotification';
 import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { useCurrentUser } from '~/stores/context';
 
 import { retrieveServerSideProps } from '../../../utils/admin-page-util';
 
@@ -22,6 +24,7 @@ const ManageGlobalNotification = dynamic(() => import('~/components/Admin/Notifi
 
 const AdminGlobalNotificationNewPage: NextPage<CommonProps> = (props) => {
   const { t } = useTranslation('admin');
+  useCurrentUser(props.currentUser ?? null);
   const router = useRouter();
   const { globalNotificationId } = router.query;
   const currentGlobalNotificationId = Array.isArray(globalNotificationId) ? globalNotificationId[0] : globalNotificationId;

+ 2 - 0
packages/app/src/pages/admin/global-notification/new.page.tsx

@@ -8,6 +8,7 @@ import { Container, Provider } from 'unstated';
 
 import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
 import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { useCurrentUser } from '~/stores/context';
 
 import { retrieveServerSideProps } from '../../../utils/admin-page-util';
 
@@ -17,6 +18,7 @@ const ManageGlobalNotification = dynamic(() => import('~/components/Admin/Notifi
 
 const AdminGlobalNotificationNewPage: NextPage<CommonProps> = (props) => {
   const { t } = useTranslation('admin');
+  useCurrentUser(props.currentUser ?? null);
 
   const title = t('external_notification.external_notification');
   const injectableContainers: Container<any>[] = [];

+ 2 - 0
packages/app/src/pages/admin/importer.page.tsx

@@ -8,6 +8,7 @@ import { Container, Provider } from 'unstated';
 
 import AdminImportContainer from '~/client/services/AdminImportContainer';
 import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { useCurrentUser } from '~/stores/context';
 
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
 
@@ -17,6 +18,7 @@ const DataImportPageContents = dynamic(() => import('~/components/Admin/ImportDa
 
 const AdminDataImportPage: NextPage<CommonProps> = (props) => {
   const { t } = useTranslation('admin');
+  useCurrentUser(props.currentUser ?? null);
 
   const title = t('importer_management.import_data');
   const injectableContainers: Container<any>[] = [];

+ 2 - 2
packages/app/src/pages/admin/index.page.tsx

@@ -10,8 +10,8 @@ import AdminHomeContainer from '~/client/services/AdminHomeContainer';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
 import PluginUtils from '~/server/plugins/plugin-utils';
+import { useCurrentUser, useGrowiCloudUri, useGrowiAppIdForGrowiCloud } from '~/stores/context';
 
-import { useGrowiCloudUri, useGrowiAppIdForGrowiCloud } from '../../stores/context';
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
 
 const AdminLayout = dynamic(() => import('~/components/Layout/AdminLayout'), { ssr: false });
@@ -29,7 +29,7 @@ type Props = CommonProps & {
 
 
 const AdminHomePage: NextPage<Props> = (props) => {
-
+  useCurrentUser(props.currentUser ?? null);
   useGrowiCloudUri(props.growiCloudUri);
   useGrowiAppIdForGrowiCloud(props.growiAppIdForGrowiCloud);
 

+ 3 - 0
packages/app/src/pages/admin/markdown.page.tsx

@@ -6,8 +6,10 @@ import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import { Container, Provider } from 'unstated';
 
+
 import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
 import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { useCurrentUser } from '~/stores/context';
 
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
 
@@ -17,6 +19,7 @@ const MarkDownSettingContents = dynamic(() => import('~/components/Admin/Markdow
 
 const AdminMarkdownPage: NextPage<CommonProps> = (props) => {
   const { t } = useTranslation('admin');
+  useCurrentUser(props.currentUser ?? null);
 
   const title = t('markdown_settings.markdown_settings');
   const injectableContainers: Container<any>[] = [];

+ 2 - 0
packages/app/src/pages/admin/notification.page.tsx

@@ -8,6 +8,7 @@ import { Container, Provider } from 'unstated';
 
 import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
 import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { useCurrentUser } from '~/stores/context';
 
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
 
@@ -17,6 +18,7 @@ const NotificationSetting = dynamic(() => import('~/components/Admin/Notificatio
 
 const AdminExternalNotificationPage: NextPage<CommonProps> = (props) => {
   const { t } = useTranslation('admin');
+  useCurrentUser(props.currentUser ?? null);
 
   const title = t('external_notification.external_notification');
   const injectableContainers: Container<any>[] = [];

+ 2 - 1
packages/app/src/pages/admin/search.page.tsx

@@ -6,7 +6,7 @@ import dynamic from 'next/dynamic';
 
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
-import { useIsSearchServiceReachable } from '~/stores/context';
+import { useIsSearchServiceReachable, useCurrentUser } from '~/stores/context';
 
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
 
@@ -23,6 +23,7 @@ type Props = CommonProps & {
 
 const AdminFullTextSearchManagementPage: NextPage<Props> = (props) => {
   const { t } = useTranslation('admin');
+  useCurrentUser(props.currentUser ?? null);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
 
   const title = t('full_text_search_management.full_text_search_management');

+ 2 - 1
packages/app/src/pages/admin/security.page.tsx

@@ -18,7 +18,7 @@ import AdminSamlSecurityContainer from '~/client/services/AdminSamlSecurityConta
 import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurityContainer';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
-import { useIsMailerSetup, useSiteUrl } from '~/stores/context';
+import { useCurrentUser, useIsMailerSetup, useSiteUrl } from '~/stores/context';
 
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
 
@@ -34,6 +34,7 @@ type Props = CommonProps & {
 
 const AdminSecuritySettingsPage: NextPage<Props> = (props) => {
   const { t } = useTranslation('admin');
+  useCurrentUser(props.currentUser ?? null);
   useSiteUrl(props.siteUrl);
   useIsMailerSetup(props.isMailerSetup);
 

+ 2 - 0
packages/app/src/pages/admin/slack-integration-legacy.page.tsx

@@ -8,6 +8,7 @@ import { Container, Provider } from 'unstated';
 
 import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
 import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { useCurrentUser } from '~/stores/context';
 
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
 
@@ -17,6 +18,7 @@ const LegacySlackIntegration = dynamic(() => import('~/components/Admin/LegacySl
 
 const AdminLegacySlackIntegrationPage: NextPage<CommonProps> = (props) => {
   const { t } = useTranslation('admin');
+  useCurrentUser(props.currentUser ?? null);
 
   const title = t('slack_integration_legacy.slack_integration_legacy');
   const injectableContainers: Container<any>[] = [];

+ 2 - 1
packages/app/src/pages/admin/slack-integration.page.tsx

@@ -6,7 +6,7 @@ import dynamic from 'next/dynamic';
 
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
-import { useSiteUrl } from '~/stores/context';
+import { useCurrentUser, useSiteUrl } from '~/stores/context';
 
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
 
@@ -22,6 +22,7 @@ type Props = CommonProps & {
 
 const AdminSlackIntegrationPage: NextPage<Props> = (props) => {
   const { t } = useTranslation('admin');
+  useCurrentUser(props.currentUser ?? null);
   useSiteUrl(props.siteUrl);
 
   const title = t('slack_integration.slack_integration');

+ 2 - 1
packages/app/src/pages/admin/user-group-detail/[userGroupId].page.tsx

@@ -7,7 +7,7 @@ import { useRouter } from 'next/router';
 
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
-import { useIsAclEnabled } from '~/stores/context';
+import { useIsAclEnabled, useCurrentUser } from '~/stores/context';
 import { useIsMaintenanceMode } from '~/stores/maintenanceMode';
 
 import { retrieveServerSideProps } from '../../../utils/admin-page-util';
@@ -22,6 +22,7 @@ type Props = CommonProps & {
 const AdminUserGroupDetailPage: NextPage<Props> = (props: Props) => {
   const { t } = useTranslation('admin');
   useIsMaintenanceMode(props.isMaintenanceMode);
+  useCurrentUser(props.currentUser ?? null);
   const router = useRouter();
   const { userGroupId } = router.query;
 

+ 2 - 1
packages/app/src/pages/admin/user-groups.page.tsx

@@ -6,7 +6,7 @@ import dynamic from 'next/dynamic';
 
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
-import { useIsAclEnabled } from '~/stores/context';
+import { useIsAclEnabled, useCurrentUser } from '~/stores/context';
 
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
 
@@ -21,6 +21,7 @@ type Props = CommonProps & {
 
 const AdminUserGroupPage: NextPage<Props> = (props) => {
   const { t } = useTranslation('admin');
+  useCurrentUser(props.currentUser ?? null);
   useIsAclEnabled(props.isAclEnabled);
 
   const title = t('user_group_management.user_group_management');

+ 2 - 0
packages/app/src/pages/admin/users/external-accounts.page.tsx

@@ -8,6 +8,7 @@ import { Container, Provider } from 'unstated';
 
 import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
 import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { useCurrentUser } from '~/stores/context';
 
 import { retrieveServerSideProps } from '../../../utils/admin-page-util';
 
@@ -17,6 +18,7 @@ const ManageExternalAccount = dynamic(() => import('~/components/Admin/ManageExt
 
 const AdminUserManagementPage: NextPage<CommonProps> = (props) => {
   const { t } = useTranslation('admin');
+  useCurrentUser(props.currentUser ?? null);
 
   const title = t('user_management.external_account');
   const injectableContainers: Container<any>[] = [];

+ 2 - 6
packages/app/src/pages/admin/users/index.page.tsx

@@ -19,14 +19,13 @@ const UserManagement = dynamic(() => import('~/components/Admin/UserManagement')
 
 
 type Props = CommonProps & {
-  currentUser: any,
   isMailerSetup: boolean,
 };
 
 
 const AdminUserManagementPage: NextPage<Props> = (props) => {
   const { t } = useTranslation('admin');
-  useCurrentUser(props.currentUser != null ? JSON.parse(props.currentUser) : null);
+  useCurrentUser(props.currentUser ?? null);
   useIsMailerSetup(props.isMailerSetup);
 
   const title = t('user_management.user_management');
@@ -51,12 +50,9 @@ const AdminUserManagementPage: NextPage<Props> = (props) => {
 
 const injectServerConfigurations = async(context: GetServerSidePropsContext, props: Props): Promise<void> => {
   const req: CrowiRequest = context.req as CrowiRequest;
-  const { crowi, user } = req;
+  const { crowi } = req;
   const { mailService } = crowi;
 
-  if (user != null) {
-    props.currentUser = JSON.stringify(user);
-  }
   props.isMailerSetup = mailService.isMailerSetup;
 };
 

+ 1 - 4
packages/app/src/pages/me/[[...path]].page.tsx

@@ -1,8 +1,6 @@
 import React, { useMemo } from 'react';
 
-import {
-  IUser, IUserHasId,
-} from '@growi/core';
+import { IUserHasId } from '@growi/core';
 import { model as mongooseModel } from 'mongoose';
 import {
   NextPage, GetServerSideProps, GetServerSidePropsContext,
@@ -36,7 +34,6 @@ import {
 const logger = loggerFactory('growi:pages:me');
 
 type Props = CommonProps & {
-  currentUser: IUser,
   isSearchServiceConfigured: boolean,
   isSearchServiceReachable: boolean,
   isSearchScopeChildrenAsDefault: boolean,

+ 1 - 6
packages/app/src/pages/share/[[...path]].page.tsx

@@ -38,7 +38,6 @@ const ForbiddenPage = dynamic(() => import('~/components/ForbiddenPage'), { ssr:
 type Props = CommonProps & {
   shareLink?: IShareLinkHasId,
   isExpired: boolean,
-  currentUser: IUser,
   disableLinkSharing: boolean,
   isSearchServiceConfigured: boolean,
   isSearchServiceReachable: boolean,
@@ -212,7 +211,7 @@ async function addActivity(context: GetServerSidePropsContext, action: Supported
 
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
   const req = context.req as CrowiRequest<IUserHasId & any>;
-  const { user, crowi, params } = req;
+  const { crowi, params } = req;
   const result = await getServerSideCommonProps(context);
 
   if (!('props' in result)) {
@@ -220,10 +219,6 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
   }
   const props: Props = result.props as Props;
 
-  if (user != null) {
-    props.currentUser = user.toObject();
-  }
-
   try {
     const ShareLinkModel = crowi.model('ShareLink');
     const shareLink = await ShareLinkModel.findOne({ _id: params.linkId }).populate('relatedPage');

+ 12 - 3
packages/app/src/pages/utils/commons.ts

@@ -1,4 +1,6 @@
-import { DevidedPagePath, Lang, AllLang } from '@growi/core';
+import {
+  DevidedPagePath, Lang, AllLang, IUser, IUserHasId,
+} from '@growi/core';
 import { GetServerSideProps, GetServerSidePropsContext } from 'next';
 import { SSRConfig, UserConfig } from 'next-i18next';
 
@@ -22,13 +24,14 @@ export type CommonProps = {
   isMaintenanceMode: boolean,
   redirectDestination: string | null,
   customizedLogoSrc?: string,
+  currentUser?: IUser,
 } & Partial<SSRConfig>;
 
 // eslint-disable-next-line max-len
 export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(context: GetServerSidePropsContext) => {
 
-  const req: CrowiRequest = context.req as CrowiRequest;
-  const { crowi } = req;
+  const req = context.req as CrowiRequest<IUserHasId & any>;
+  const { crowi, user } = req;
   const {
     appService, configManager, customizeService,
   } = crowi;
@@ -38,6 +41,11 @@ export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(c
 
   const isMaintenanceMode = appService.isMaintenanceMode();
 
+  let currentUser;
+  if (user != null) {
+    currentUser = user.toObject();
+  }
+
   // eslint-disable-next-line max-len, no-nested-ternary
   const redirectDestination = !isMaintenanceMode && currentPathname === '/maintenance' ? '/' : isMaintenanceMode && !currentPathname.match('/admin/*') && !(currentPathname === '/maintenance') ? '/maintenance' : null;
   const isDefaultLogo = crowi.configManager.getConfig('crowi', 'customize:isDefaultLogo');
@@ -56,6 +64,7 @@ export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(c
     isMaintenanceMode,
     redirectDestination,
     customizedLogoSrc: isDefaultLogo ? null : configManager.getConfig('crowi', 'customize:customizedLogoSrc'),
+    currentUser,
   };
 
   return { props };

+ 4 - 2
packages/app/src/server/routes/apiv3/page.js

@@ -255,7 +255,9 @@ module.exports = (crowi) => {
    */
   router.get('/', certifySharedPage, accessTokenParser, loginRequired, validator.getPage, apiV3FormValidator, async(req, res) => {
     const { user } = req;
-    const { pageId, path, findAll } = req.query;
+    const {
+      pageId, path, findAll, revisionId,
+    } = req.query;
 
     if (pageId == null && path == null) {
       return res.apiv3Err(new ErrorV3('Either parameter of path or pageId is required.', 'invalid-request'));
@@ -285,7 +287,7 @@ module.exports = (crowi) => {
 
     if (page != null) {
       try {
-        page.initLatestRevisionField();
+        page.initLatestRevisionField(revisionId);
 
         // populate
         page = await page.populateDataToShowRevision();

+ 2 - 2
packages/app/src/stores/context.tsx

@@ -48,8 +48,8 @@ export const useCurrentUser = (initialData?: Nullable<IUser>): SWRResponse<Nulla
   return useContextSWR<Nullable<IUser>, Error>('currentUser', initialData);
 };
 
-export const useRevisionId = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useContextSWR<Nullable<any>, Error>('revisionId', initialData);
+export const useCurrentRevisionId = (initialData?: string): SWRResponse<string, Error> => {
+  return useContextSWR('currentRevisionId', initialData);
 };
 
 export const useCurrentPathname = (initialData?: string): SWRResponse<string, Error> => {

+ 38 - 3
packages/app/src/stores/modal.tsx

@@ -1,5 +1,6 @@
 import { SWRResponse } from 'swr';
 
+import MarkdownTable from '~/client/models/MarkdownTable';
 import { IPageToDeleteWithMeta, IPageToRenameWithMeta } from '~/interfaces/page';
 import {
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction,
@@ -8,7 +9,6 @@ import { IUserGroupHasId } from '~/interfaces/user';
 
 import { useStaticSWR } from './use-static-swr';
 
-
 /*
 * PageCreateModal
 */
@@ -461,13 +461,48 @@ export const useDrawioModal = (status?: DrawioModalStatus): SWRResponse<DrawioMo
   };
   const swrResponse = useStaticSWR<DrawioModalStatus, Error>('drawioModalStatus', status, { fallbackData: initialData });
 
+  const open = (drawioMxFile: string): void => {
+    swrResponse.mutate({ isOpened: true, drawioMxFile });
+  };
+
   const close = (): void => {
     swrResponse.mutate({ isOpened: false, drawioMxFile: '' });
   };
 
-  const open = (drawioMxFile: string): void => {
-    swrResponse.mutate({ isOpened: true, drawioMxFile });
+  return {
+    ...swrResponse,
+    open,
+    close,
+  };
+};
+
+/*
+* HandsonTableModal
+*/
+type HandsontableModalStatus = {
+  isOpened: boolean,
+  table?: MarkdownTable,
+  editor: any,
+  autoFormatMarkdownTable: boolean,
+}
+
+type HandsontableModalStatusUtils = {
+  open(table: MarkdownTable, editor: any, autoFormatMarkdownTable: boolean): Promise<HandsontableModalStatus | undefined>
+  close(): Promise<HandsontableModalStatus | undefined>
+}
+
+export const useHandsontableModal = (status?: HandsontableModalStatus): SWRResponse<HandsontableModalStatus, Error> & HandsontableModalStatusUtils => {
+  const initialData: HandsontableModalStatus = {
+    isOpened: false, table: undefined, editor: undefined, autoFormatMarkdownTable: false,
   };
+  const swrResponse = useStaticSWR<HandsontableModalStatus, Error>('handsontableModalStatus', status, { fallbackData: initialData });
+
+  const open = (table: MarkdownTable, editor: any, autoFormatMarkdownTable: boolean) => swrResponse.mutate({
+    isOpened: true, table, editor, autoFormatMarkdownTable,
+  });
+  const close = () => swrResponse.mutate({
+    isOpened: false, table: undefined, editor: undefined, autoFormatMarkdownTable: false,
+  });
 
   return {
     ...swrResponse,

+ 7 - 5
packages/app/src/stores/page.tsx

@@ -15,8 +15,8 @@ import { IRevisionsForPagination } from '~/interfaces/revision';
 
 import { IPageTagsInfo } from '../interfaces/tag';
 
-import { useCurrentPageId, useCurrentPathname } from './context';
-
+import { useCurrentPageId, useCurrentPathname, useCurrentRevisionId } from './context';
+import { useStaticSWR } from './use-static-swr';
 
 const { isPermalink: _isPermalink } = pagePathUtils;
 
@@ -24,11 +24,12 @@ const { isPermalink: _isPermalink } = pagePathUtils;
 export const useSWRxPage = (
     pageId?: string|null,
     shareLinkId?: string,
+    revisionId?: string,
     initialData?: IPagePopulatedToShowRevision|null,
 ): SWRResponse<IPagePopulatedToShowRevision|null, Error> => {
   return useSWR<IPagePopulatedToShowRevision|null, Error>(
-    pageId != null ? ['/page', pageId, shareLinkId] : null,
-    (endpoint, pageId, shareLinkId) => apiv3Get<{ page: IPagePopulatedToShowRevision }>(endpoint, { pageId, shareLinkId })
+    pageId != null ? ['/page', pageId, shareLinkId, revisionId] : null,
+    (endpoint, pageId, shareLinkId, revisionId) => apiv3Get<{ page: IPagePopulatedToShowRevision }>(endpoint, { pageId, shareLinkId, revisionId })
       .then(result => result.data.page)
       .catch((errs) => {
         if (!Array.isArray(errs)) { throw Error('error is not array') }
@@ -54,8 +55,9 @@ export const useSWRxCurrentPage = (
     shareLinkId?: string, initialData?: IPagePopulatedToShowRevision|null,
 ): SWRResponse<IPagePopulatedToShowRevision|null, Error> => {
   const { data: currentPageId } = useCurrentPageId();
+  const { data: currentRevisionId } = useCurrentRevisionId();
 
-  const swrResult = useSWRxPage(currentPageId, shareLinkId, initialData);
+  const swrResult = useSWRxPage(currentPageId, shareLinkId, currentRevisionId, initialData);
 
   return swrResult;
 };

+ 0 - 19
packages/app/src/styles/style-next.scss

@@ -147,22 +147,3 @@
   }
 
 }
-
-// Prevent handsontable/handsontable #2937 (Manual column resize does not work when handsontable is loaded inside Bootstrap 3.0 Modal)
-// see https://github.com/handsontable/handsontable/issues/2937#issuecomment-287390111
-.modal.in .modal-dialog.handsontable-modal {
-  transform: none;
-
-  .data-import-button {
-    position: relative;
-    padding-right: 35px;
-    padding-left: 10px;
-
-    i:before {
-      position: absolute;
-      top: 6px;
-      right: 8px;
-      font-size: 20px;
-    }
-  }
-}

+ 13 - 2
packages/app/src/utils/admin-page-util.ts

@@ -1,10 +1,13 @@
+import type { IUserHasId } from '@growi/core';
 import { GetServerSidePropsContext } from 'next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 
+import type { CrowiRequest } from '~/interfaces/crowi-request';
 import {
-  getServerSideCommonProps, getNextI18NextConfig,
+  getServerSideCommonProps, getNextI18NextConfig, CommonProps,
 } from '~/pages/utils/commons';
 
+
 /**
  * for Server Side Translations
  * @param context
@@ -21,6 +24,9 @@ async function injectNextI18NextConfigurations(context: GetServerSidePropsContex
 export const retrieveServerSideProps: any = async(
     context: GetServerSidePropsContext, injectServerConfigurations?:(context: GetServerSidePropsContext, props) => Promise<void>,
 ) => {
+  const req = context.req as CrowiRequest<IUserHasId & any>;
+  const { user } = req;
+
   const result = await getServerSideCommonProps(context);
 
   // check for presence
@@ -29,10 +35,15 @@ export const retrieveServerSideProps: any = async(
     throw new Error('invalid getSSP result');
   }
 
-  const props = result.props;
+  const props: CommonProps = result.props as CommonProps;
   if (injectServerConfigurations != null) {
     await injectServerConfigurations(context, props);
   }
+
+  if (user != null) {
+    props.currentUser = user.toObject();
+  }
+
   await injectNextI18NextConfigurations(context, props, ['admin', 'commons']);
 
   return {

+ 1 - 0
packages/app/test/cypress/integration/20-basic-features/use-tools.spec.ts

@@ -206,6 +206,7 @@ context('Page Accessories Modal', () => {
       cy.getByTestid('open-page-item-control-btn').within(() => {
         cy.get('button.btn-page-item-control').click({force: true});
       });
+      cy.getByTestid('open-page-accessories-modal-btn-with-share-link-management-data-tab').should('be.visible');
       cy.getByTestid('open-page-accessories-modal-btn-with-share-link-management-data-tab').click();
    });