jam411 3 лет назад
Родитель
Сommit
97a1f4ffe5
77 измененных файлов с 996 добавлено и 840 удалено
  1. 20 1
      CHANGELOG.md
  2. 1 1
      lerna.json
  3. 1 1
      package.json
  4. 2 2
      packages/app/bin/github-actions/update-readme.sh
  5. 1 1
      packages/app/config/rate-limiter.ts
  6. 2 2
      packages/app/docker/README.md
  7. 7 7
      packages/app/package.json
  8. 4 0
      packages/app/public/static/locales/en_US/translation.json
  9. 4 0
      packages/app/public/static/locales/ja_JP/translation.json
  10. 4 0
      packages/app/public/static/locales/zh_CN/translation.json
  11. 1 1
      packages/app/src/client/services/ContextExtractor.tsx
  12. 1 1
      packages/app/src/components/Admin/UserGroupDetail/UpdateParentConfirmModal.tsx
  13. 2 6
      packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  14. 15 6
      packages/app/src/components/Comments.tsx
  15. 10 2
      packages/app/src/components/Common/ClosableTextInput.tsx
  16. 2 1
      packages/app/src/components/CustomNavigation/CustomTabContent.tsx
  17. 3 3
      packages/app/src/components/EmptyTrashButton.tsx
  18. 2 2
      packages/app/src/components/IdenticalPathPage.tsx
  19. 0 0
      packages/app/src/components/Invited.module.scss
  20. 111 0
      packages/app/src/components/InvitedForm.tsx
  21. 1 2
      packages/app/src/components/Layout/NoLoginLayout.tsx
  22. 12 18
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  23. 5 6
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  24. 107 86
      packages/app/src/components/Page/DisplaySwitcher.tsx
  25. 1 0
      packages/app/src/components/Page/RevisionRenderer.tsx
  26. 5 3
      packages/app/src/components/PageAlert/TrashPageAlert.tsx
  27. 3 7
      packages/app/src/components/PageComment.tsx
  28. 21 16
      packages/app/src/components/PageComment/Comment.tsx
  29. 6 10
      packages/app/src/components/PageComment/CommentEditor.module.scss
  30. 19 18
      packages/app/src/components/PageComment/CommentEditor.tsx
  31. 0 47
      packages/app/src/components/PageComment/CommentEditorLazyRenderer.tsx
  32. 9 0
      packages/app/src/components/PageComment/CommentPreview.module.scss
  33. 12 4
      packages/app/src/components/PageComment/CommentPreview.tsx
  34. 1 5
      packages/app/src/components/PageComment/ReplyComments.tsx
  35. 2 0
      packages/app/src/components/PageComment/_comment-inheritance.scss
  36. 9 17
      packages/app/src/components/PageEditor.tsx
  37. 8 1
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  38. 2 1
      packages/app/src/components/PageEditor/Editor.module.scss
  39. 28 46
      packages/app/src/components/PageEditor/Editor.tsx
  40. 1 0
      packages/app/src/components/PageEditor/_page-editor-inheritance.scss
  41. 8 5
      packages/app/src/components/PagePathNav.tsx
  42. 10 4
      packages/app/src/components/PageStatusAlert.jsx
  43. 1 1
      packages/app/src/components/ShareLink/ShareLink.tsx
  44. 0 277
      packages/app/src/components/ShareLink/ShareLinkForm.jsx
  45. 212 0
      packages/app/src/components/ShareLink/ShareLinkForm.tsx
  46. 31 14
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  47. 9 0
      packages/app/src/components/Sidebar/RecentChanges.module.scss
  48. 1 1
      packages/app/src/components/Sidebar/RecentChanges.tsx
  49. 5 2
      packages/app/src/interfaces/editor-methods.ts
  50. 1 1
      packages/app/src/interfaces/ui.ts
  51. 3 4
      packages/app/src/pages/[[...path]].page.tsx
  52. 2 4
      packages/app/src/pages/installer.page.tsx
  53. 87 0
      packages/app/src/pages/invited.page.tsx
  54. 4 8
      packages/app/src/pages/login.page.tsx
  55. 16 7
      packages/app/src/pages/trash.page.tsx
  56. 13 10
      packages/app/src/server/crowi/dev.js
  57. 1 1
      packages/app/src/server/middlewares/login-required.js
  58. 5 0
      packages/app/src/server/routes/apiv3/page.js
  59. 3 3
      packages/app/src/server/routes/index.js
  60. 21 14
      packages/app/src/server/service/global-notification/global-notification-mail.js
  61. 1 1
      packages/app/src/server/service/global-notification/index.js
  62. 4 1
      packages/app/src/styles/style-next.scss
  63. 1 1
      packages/app/test/cypress/integration/10-install/install.spec.ts
  64. 1 1
      packages/app/test/cypress/integration/20-basic-features/use-tools.spec.ts
  65. 1 1
      packages/app/test/cypress/integration/50-sidebar/access-to-side-bar.spec.ts
  66. 3 3
      packages/app/test/integration/middlewares/login-required.test.js
  67. 1 1
      packages/codemirror-textlint/package.json
  68. 1 1
      packages/core/package.json
  69. 1 1
      packages/plugin-attachment-refs/package.json
  70. 3 3
      packages/plugin-lsx/package.json
  71. 0 109
      packages/plugin-lsx/src/components/LsxPageList/LsxPage.jsx
  72. 95 0
      packages/plugin-lsx/src/components/LsxPageList/LsxPage.tsx
  73. 0 31
      packages/plugin-lsx/src/components/LsxPageList/PagePathWrapper.jsx
  74. 1 1
      packages/remark-growi-plugin/package.json
  75. 1 1
      packages/slack/package.json
  76. 2 2
      packages/slackbot-proxy/package.json
  77. 2 2
      packages/ui/package.json

+ 20 - 1
CHANGELOG.md

@@ -1,9 +1,28 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v5.1.3...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v5.1.4...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v5.1.4](https://github.com/weseek/growi/compare/v5.1.3...v5.1.4) - 2022-09-12
+
+### 💎 Features
+
+- feat:  Truncate long path when recent changes is in S size (#6263) @mudana-grune
+- feat: In-app notifications when removing descendants of subscribed pages (#6192) @Shunm634-source
+- feat: Not increment ordered list number in CodeMirror (#6462) @mudana-grune
+
+### 🚀 Improvement
+
+- imprv: Added page URL to mail subject (#6554) @hakumizuki
+
+### 🐛 Bug Fixes
+
+- fix: Cannot update user group without parent (#6530) @kaoritokashiki
+- fix: Make PageTree input not draggable when editting (#6525) @hakumizuki
+- fix: Pagetree input hit enter (#6526) @hakumizuki
+- fix: Disallow retrieval of revision data that does not match the page (#6537) @miya
+
 ## [v5.1.3](https://github.com/weseek/growi/compare/v5.1.2...v5.1.3) - 2022-08-28
 
 ### 💎 Features

+ 1 - 1
lerna.json

@@ -1,7 +1,7 @@
 {
   "npmClient": "yarn",
   "useWorkspaces": true,
-  "version": "5.1.4-RC.0",
+  "version": "5.1.5-RC.0",
   "packages": [
     "packages/*"
   ]

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "5.1.4-RC.0",
+  "version": "5.1.5-RC.0",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",

+ 2 - 2
packages/app/bin/github-actions/update-readme.sh

@@ -2,5 +2,5 @@
 
 cd docker
 
-sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`5\.1\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASED_VERSION}\2\3${RELEASED_VERSION}\4/" README.md
-sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`5\.1-nocdn\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASED_VERSION}-nocdn\2\3${RELEASED_VERSION}\4/" README.md
+sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`5\.1\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/packages\/app\/docker\/Dockerfile.\+\)$/\1${RELEASED_VERSION}\2\3${RELEASED_VERSION}\4/" README.md
+sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`5\.1-nocdn\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/packages\/app\/docker\/Dockerfile.\+\)$/\1${RELEASED_VERSION}-nocdn\2\3${RELEASED_VERSION}\4/" README.md

+ 1 - 1
packages/app/config/rate-limiter.ts

@@ -33,7 +33,7 @@ export const defaultConfig: IApiRateLimitEndpointMap = {
     maxRequests: MAX_REQUESTS_TIER_1,
     usersPerIpProspection: 100,
   },
-  '/login/activateInvited': {
+  '/invited/activateInvited': {
     method: 'POST',
     maxRequests: MAX_REQUESTS_TIER_2,
   },

+ 2 - 2
packages/app/docker/README.md

@@ -10,8 +10,8 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`5.1.3`, `5.1`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.3/packages/app/docker/Dockerfile)
-* [`5.1.3-nocdn`, `5.1-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.3/packages/app/docker/Dockerfile)
+* [`5.1.4`, `5.1`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.4/packages/app/docker/Dockerfile)
+* [`5.1.4-nocdn`, `5.1-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.4/packages/app/docker/Dockerfile)
 * [`5.0.11`, `5.0` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.11/packages/app/docker/Dockerfile)
 * [`5.0.11-nocdn`, `5.0-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.11/packages/app/docker/Dockerfile)
 * [`4.5.23`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.23/packages/app/docker/Dockerfile)

+ 7 - 7
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "5.1.4-RC.0",
+  "version": "5.1.5-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -63,11 +63,11 @@
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^5.1.4-RC.0",
-    "@growi/core": "^5.1.4-RC.0",
-    "@growi/plugin-attachment-refs": "^5.1.4-RC.0",
-    "@growi/plugin-lsx": "^5.1.4-RC.0",
-    "@growi/slack": "^5.1.4-RC.0",
+    "@growi/codemirror-textlint": "^5.1.5-RC.0",
+    "@growi/core": "^5.1.5-RC.0",
+    "@growi/plugin-attachment-refs": "^5.1.5-RC.0",
+    "@growi/plugin-lsx": "^5.1.5-RC.0",
+    "@growi/slack": "^5.1.5-RC.0",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
@@ -191,7 +191,7 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^5.1.4-RC.0",
+    "@growi/ui": "^5.1.5-RC.0",
     "@handsontable/react": "=2.1.0",
     "@icon/themify-icons": "1.0.1-alpha.3",
     "@next/bundle-analyzer": "^12.2.3",

+ 4 - 0
packages/app/public/static/locales/en_US/translation.json

@@ -675,6 +675,10 @@
     "Registration successful": "Registration successful",
     "Setup": "Setup"
   },
+  "invited": {
+    "discription_heading": "Create Account",
+    "discription": "Create an your account with the invited email address"
+  },
   "export_bulk": {
     "failed_to_export": "Failed to export",
     "failed_to_count_pages": "Failed to count pages",

+ 4 - 0
packages/app/public/static/locales/ja_JP/translation.json

@@ -666,6 +666,10 @@
     "Registration successful": "登録完了",
     "Setup": "セットアップ"
   },
+  "invited": {
+    "discription_heading": "アカウント作成",
+    "discription": "招待を受け取ったメールアドレスでアカウントを作成します"
+  },
   "export_bulk": {
     "failed_to_export": "ページのエクスポートに失敗しました",
     "failed_to_count_pages": "ページ数の取得に失敗しました",

+ 4 - 0
packages/app/public/static/locales/zh_CN/translation.json

@@ -722,6 +722,10 @@
 		"Registration successful": "注册成功",
 		"Setup": "安装程序"
 	},
+  "invited": {
+    "discription_heading": "创建账户",
+    "discription": "用被邀请的电子邮件地址创建一个你的账户"
+  },
   "export_bulk": {
     "failed_to_export": "导出失败",
     "failed_to_count_pages": "页面计数失败",

+ 1 - 1
packages/app/src/client/services/ContextExtractor.tsx

@@ -145,7 +145,7 @@ const ContextExtractorOnce: FC = () => {
   useIsIdenticalPath(isIdenticalPath);
   useIsNotCreatable(isNotCreatable);
   useIsForbidden(isForbidden);
-  useIsTrashPage(isTrashPage);
+  // useIsTrashPage(isTrashPage);
   useIsUserPage(isUserPage);
   useLastUpdateUsername(lastUpdateUsername);
   useCurrentPageId(pageId);

+ 1 - 1
packages/app/src/components/Admin/UserGroupDetail/UpdateParentConfirmModal.tsx

@@ -30,7 +30,7 @@ export const UpdateParentConfirmModal: FC = () => {
         <i className="icon icon-warning"></i> {t('admin:user_group_management.update_parent_confirm_modal.header')}
       </ModalHeader>
       {
-        targetGroup != null && updateData != null && updateData?.parent !== undefined ? (
+        targetGroup != null && updateData != null ? (
           <>
             <ModalBody>
               <div className="mb-2">

+ 2 - 6
packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -105,15 +105,11 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
   }, []);
 
   const updateUserGroup = useCallback(async(userGroup: IUserGroupHasId, update: Partial<IUserGroupHasId>, forceUpdateParents: boolean) => {
-    if (update.parent == null) {
-      throw Error('"parent" attr must not be null');
-    }
-
     const parentId = typeof update.parent === 'string' ? update.parent : update.parent?._id;
     const res = await apiv3Put<{ userGroup: IUserGroupHasId }>(`/user-groups/${userGroup._id}`, {
       name: update.name,
       description: update.description,
-      parentId,
+      parentId: parentId ?? null,
       forceUpdateParents,
     });
     const { userGroup: updatedUserGroup } = res.data;
@@ -138,7 +134,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
   );
 
   const onClickSubmitForm = useCallback(async(targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>): Promise<void> => {
-    if (userGroupData?.parent === undefined || typeof userGroupData?.parent === 'string') {
+    if (typeof userGroupData?.parent === 'string') {
       toastError(t('Something went wrong. Please try again.'));
       return;
     }

+ 15 - 6
packages/app/src/components/Comments.tsx

@@ -1,11 +1,17 @@
 import React from 'react';
 
+import dynamic from 'next/dynamic';
+
 import { PageComment } from '~/components/PageComment';
-import { useCommentPreviewOptions } from '~/stores/renderer';
+import { useSWRxPageComment } from '~/stores/comment';
 
 import { useIsTrashPage } from '../stores/context';
 
-import { CommentEditorLazyRenderer } from './PageComment/CommentEditorLazyRenderer';
+import { CommentEditorProps } from './PageComment/CommentEditor';
+
+
+const CommentEditor = dynamic<CommentEditorProps>(() => import('./PageComment/CommentEditor').then(mod => mod.CommentEditor), { ssr: false });
+
 
 type CommentsProps = {
   pageId?: string,
@@ -15,11 +21,10 @@ export const Comments = (props: CommentsProps): JSX.Element => {
 
   const { pageId } = props;
 
-  const { data: rendererOptions } = useCommentPreviewOptions();
+  const { mutate } = useSWRxPageComment(pageId);
   const { data: isDeleted } = useIsTrashPage();
 
-  // TODO: Implement or refactor Skelton if server-side rendering
-  if (rendererOptions == null || isDeleted == null) {
+  if (pageId == null) {
     return <></>;
   }
 
@@ -33,7 +38,11 @@ export const Comments = (props: CommentsProps): JSX.Element => {
           </div>
           { !isDeleted && (
             <div id="page-comment-write">
-              <CommentEditorLazyRenderer pageId={pageId} rendererOptions={rendererOptions} />
+              <CommentEditor
+                pageId={pageId}
+                isForNewComment
+                onCommentButtonClicked={mutate}
+              />
             </div>
           )}
         </div>

+ 10 - 2
packages/app/src/components/Common/ClosableTextInput.tsx

@@ -30,7 +30,8 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
 
   const [inputText, setInputText] = useState(props.value);
   const [currentAlertInfo, setAlertInfo] = useState<AlertInfo | null>(null);
-  const [isAbleToShowAlert, setIsAbleToShowAlert] = useState<boolean>(false);
+  const [isAbleToShowAlert, setIsAbleToShowAlert] = useState(false);
+  const [isComposing, setComposing] = useState(false);
 
   const createValidation = async(inputText: string) => {
     if (props.inputValidator != null) {
@@ -63,6 +64,10 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
   const onKeyDownHandler = (e) => {
     switch (e.key) {
       case 'Enter':
+        // Do nothing when composing
+        if (isComposing) {
+          return;
+        }
         onPressEnter();
         break;
       default:
@@ -107,7 +112,7 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
 
 
   return (
-    <div className="d-block flex-fill">
+    <div>
       <input
         value={inputText || ''}
         ref={inputRef}
@@ -115,9 +120,12 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
         className="form-control"
         placeholder={props.placeholder}
         name="input"
+        data-testid="closable-text-input"
         onFocus={onFocusHandler}
         onChange={onChangeHandler}
         onKeyDown={onKeyDownHandler}
+        onCompositionStart={() => setComposing(true)}
+        onCompositionEnd={() => setComposing(false)}
         onBlur={onBlurHandler}
         autoFocus={false}
       />

+ 2 - 1
packages/app/src/components/CustomNavigation/CustomTabContent.tsx

@@ -1,4 +1,5 @@
 import React, { useEffect, useState } from 'react';
+
 import PropTypes from 'prop-types';
 import {
   TabContent, TabPane,
@@ -18,7 +19,7 @@ const CustomTabContent = (props: Props): JSX.Element => {
 
   const { activeTab, navTabMapping, additionalClassNames } = props;
 
-  const [activatedContent, setActivatedContent] = useState<Set<string>>(new Set<string>());
+  const [activatedContent, setActivatedContent] = useState(new Set([activeTab]));
 
   // add activated content to Set
   useEffect(() => {

+ 3 - 3
packages/app/src/components/EmptyTrashButton.tsx

@@ -1,4 +1,4 @@
-import React, { useCallback } from 'react';
+import React, { FC, useCallback } from 'react';
 
 import { useTranslation } from 'next-i18next';
 
@@ -12,7 +12,7 @@ import { useEmptyTrashModal } from '~/stores/modal';
 import { useSWRxDescendantsPageListForCurrrentPath, useSWRxPageInfoForList } from '~/stores/page-listing';
 
 
-const EmptyTrashButton = () => {
+const EmptyTrashButton: FC = () => {
   const { t } = useTranslation();
   const { open: openEmptyTrashModal } = useEmptyTrashModal();
   const { data: pagingResult, mutate } = useSWRxDescendantsPageListForCurrrentPath();
@@ -40,7 +40,6 @@ const EmptyTrashButton = () => {
   }, [t, mutate]);
 
   const emptyTrashClickHandler = () => {
-    if (deletablePages.length === 0) { return }
     openEmptyTrashModal(deletablePages, { onEmptiedTrash: onEmptiedTrashHandler, canDelepeAllPages: pagingResult?.totalCount === deletablePages.length });
   };
 
@@ -49,6 +48,7 @@ const EmptyTrashButton = () => {
       <button
         type="button"
         className="btn btn-outline-secondary rounded-pill text-danger d-flex align-items-center"
+        disabled={deletablePages.length === 0}
         onClick={() => emptyTrashClickHandler()}
       >
         <i className="icon-fw icon-trash"></i>

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

@@ -3,7 +3,7 @@ import React, { FC } from 'react';
 import { DevidedPagePath } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 
-import { useCurrentPagePath, useIsSharedUser } from '~/stores/context';
+import { useCurrentPathname, useIsSharedUser } from '~/stores/context';
 import { useDescendantsPageListModal } from '~/stores/modal';
 import { useSWRxPageInfoForList, useSWRxPagesByPath } from '~/stores/page-listing';
 
@@ -52,7 +52,7 @@ const IdenticalPathAlert : FC<IdenticalPathAlertProps> = (props: IdenticalPathAl
 export const IdenticalPathPage = (): JSX.Element => {
   const { t } = useTranslation();
 
-  const { data: currentPath } = useCurrentPagePath();
+  const { data: currentPath } = useCurrentPathname();
   const { data: isSharedUser } = useIsSharedUser();
 
   const { data: pages } = useSWRxPagesByPath(currentPath);

+ 0 - 0
packages/app/src/components/Layout/Invited.module.scss → packages/app/src/components/Invited.module.scss


+ 111 - 0
packages/app/src/components/InvitedForm.tsx

@@ -0,0 +1,111 @@
+import React from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { useCsrfToken, useCurrentUser } from '../stores/context';
+
+export type InvitedFormProps = {
+  invitedFormUsername: string,
+  invitedFormName: string,
+}
+
+export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
+  const { t } = useTranslation();
+  const { data: csrfToken } = useCsrfToken();
+  const { data: user } = useCurrentUser();
+
+  const { invitedFormUsername, invitedFormName } = props;
+
+  if (user == null) {
+    return <></>;
+  }
+
+  return (
+    <div className="noLogin-dialog p-3 mx-auto" id="noLogin-dialog">
+      <p className="alert alert-success">
+        <strong>{ t('invited.discription_heading') }</strong><br></br>
+        <small>{ t('invited.discription') }</small>
+      </p>
+      <form role="form" action="/invited/activateInvited" method="post" id="invited-form">
+        {/* Email Form */}
+        <div className="input-group">
+          <div className="input-group-prepend">
+            <span className="input-group-text">
+              <i className="icon-envelope"></i>
+            </span>
+          </div>
+          <input
+            type="text"
+            className="form-control"
+            disabled
+            placeholder={t('Email')}
+            name="invitedForm[email]"
+            defaultValue={user.email}
+            required
+          />
+        </div>
+        {/* UserID Form */}
+        <div className="input-group" id="input-group-username">
+          <div className="input-group-prepend">
+            <span className="input-group-text">
+              <i className="icon-user"></i>
+            </span>
+          </div>
+          <input
+            type="text"
+            className="form-control"
+            placeholder={t('User ID')}
+            name="invitedForm[username]"
+            value={invitedFormUsername}
+            required
+          />
+        </div>
+        {/* Name Form */}
+        <div className="input-group">
+          <div className="input-group-prepend">
+            <span className="input-group-text">
+              <i className="icon-tag"></i>
+            </span>
+          </div>
+          <input
+            type="text"
+            className="form-control"
+            placeholder={t('Name')}
+            name="invitedForm[name]"
+            value={invitedFormName}
+            required
+          />
+        </div>
+        {/* Password Form */}
+        <div className="input-group">
+          <div className="input-group-prepend">
+            <span className="input-group-text">
+              <i className="icon-lock"></i>
+            </span>
+          </div>
+          <input
+            type="password"
+            className="form-control"
+            placeholder={t('Password')}
+            name="invitedForm[password]"
+            required
+          />
+        </div>
+        {/* Create Button */}
+        <div className="input-group justify-content-center d-flex mt-5">
+          <input type="hidden" name="_csrf" value={csrfToken} />
+          <button type="submit" className="btn btn-fill" id="register">
+            <div className="eff"></div>
+            <span className="btn-label"><i className="icon-user-follow"></i></span>
+            <span className="btn-label-text">{t('Create')}</span>
+          </button>
+        </div>
+      </form>
+      <div className="input-group mt-5 d-flex justify-content-center">
+        <a href="https://growi.org" className="link-growi-org">
+          <span className="growi">GROWI</span>.<span className="org">ORG</span>
+        </a>
+      </div>
+    </div>
+  );
+};

+ 1 - 2
packages/app/src/components/Layout/NoLoginLayout.tsx

@@ -34,10 +34,9 @@ export const NoLoginLayout = ({
                     <h1 className="my-3">GROWI</h1>
                     <div className="noLogin-form-errors px-3"></div>
                   </div>
+                  {children}
                 </div>
 
-                {children}
-
               </div>
             </div>
           </div>

+ 12 - 18
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -43,6 +43,16 @@ import { SubNavButtonsProps } from './SubNavButtons';
 import PageEditorModeManagerStyles from './PageEditorModeManager.module.scss';
 
 
+const PageEditorModeManager = dynamic(
+  () => import('./PageEditorModeManager'),
+  { ssr: false, loading: () => <Skelton additionalClass={`${PageEditorModeManagerStyles['grw-page-editor-mode-manager-skelton']}`} /> },
+);
+const SubNavButtons = dynamic<SubNavButtonsProps>(
+  () => import('./SubNavButtons').then(mod => mod.SubNavButtons),
+  { ssr: false, loading: () => <Skelton additionalClass='btn-skelton py-2' /> },
+);
+
+
 type AdditionalMenuItemsProps = {
   pageId: string,
   revisionId: string,
@@ -156,15 +166,6 @@ type GrowiContextualSubNavigationProps = {
 
 const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps): JSX.Element => {
 
-  const PageEditorModeManager = dynamic(
-    () => import('./PageEditorModeManager'),
-    { ssr: false, loading: () => <Skelton additionalClass={`${PageEditorModeManagerStyles['grw-page-editor-mode-manager-skelton']}`} /> },
-  );
-  const SubNavButtons = dynamic<SubNavButtonsProps>(
-    () => import('./SubNavButtons').then(mod => mod.SubNavButtons),
-    { ssr: false, loading: () => <Skelton additionalClass='btn-skelton py-2' /> },
-  );
-
   const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
   const path = currentPage?.path;
 
@@ -353,15 +354,8 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
         )}
       </>
     );
-  }, [
-    currentPage, currentUser, pageId, revisionId, shareLinkId, path, editorMode,
-    isCompactMode, isViewMode, isSharedUser, isAbleToShowPageManagement, isAbleToShowPageEditorModeManager,
-    isLinkSharingDisabled, isGuestUser, isPageTemplateModalShown,
-    duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler,
-    PageEditorModeManager, SubNavButtons,
-    mutateEditorMode,
-    templateMenuItemClickHandler,
-  ]);
+  // eslint-disable-next-line max-len
+  }, [currentPage, currentUser, pageId, revisionId, shareLinkId, path, editorMode, isCompactMode, isViewMode, isSharedUser, isAbleToShowPageManagement, isAbleToShowPageEditorModeManager, isLinkSharingDisabled, isGuestUser, isPageTemplateModalShown, duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler, mutateEditorMode, templateMenuItemClickHandler]);
 
   if (currentPathname == null) {
     return <></>;

+ 5 - 6
packages/app/src/components/Navbar/GrowiNavbar.tsx

@@ -19,18 +19,17 @@ import { HasChildren } from '../../interfaces/common';
 import GrowiLogo from '../Icons/GrowiLogo';
 
 import { GlobalSearchProps } from './GlobalSearch';
-import PersonalDropdown from './PersonalDropdown';
 
 import styles from './GrowiNavbar.module.scss';
 
+const PersonalDropdown = dynamic(() => import('./PersonalDropdown'), { ssr: false });
+const InAppNotificationDropdown = dynamic(() => import('../InAppNotification/InAppNotificationDropdown')
+  .then(mod => mod.InAppNotificationDropdown), { ssr: false });
+const AppearanceModeDropdown = dynamic(() => import('./AppearanceModeDropdown').then(mod => mod.AppearanceModeDropdown), { ssr: false });
 
 const NavbarRight = memo((): JSX.Element => {
   const { t } = useTranslation();
 
-  const InAppNotificationDropdown = dynamic(() => import('../InAppNotification/InAppNotificationDropdown')
-    .then(mod => mod.InAppNotificationDropdown), { ssr: false });
-  const AppearanceModeDropdown = dynamic(() => import('./AppearanceModeDropdown').then(mod => mod.AppearanceModeDropdown), { ssr: false });
-
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: isGuestUser } = useIsGuestUser();
 
@@ -71,7 +70,7 @@ const NavbarRight = memo((): JSX.Element => {
         </li>
       </>
     );
-  }, [InAppNotificationDropdown, t, AppearanceModeDropdown, isAuthenticated, openCreateModal, currentPagePath]);
+  }, [t, isAuthenticated, openCreateModal, currentPagePath]);
 
   const notAuthenticatedNavItem = useMemo(() => {
     return (

+ 107 - 86
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -1,9 +1,8 @@
-import React from 'react';
+import React, { useMemo } from 'react';
 
 import { pagePathUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
-import { TabContent, TabPane } from 'reactstrap';
 
 // import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import {
@@ -14,6 +13,7 @@ import { useSWRxCurrentPage } from '~/stores/page';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 
 import CountBadge from '../Common/CountBadge';
+import CustomTabContent from '../CustomNavigation/CustomTabContent';
 import PageListIcon from '../Icons/PageListIcon';
 import { Page } from '../Page';
 // import PageEditorByHackmd from '../PageEditorByHackmd';
@@ -24,8 +24,6 @@ import { UserInfoProps } from '../User/UserInfo';
 import styles from './DisplaySwitcher.module.scss';
 
 
-const WIKI_HEADER_LINK = 120;
-
 const { isTopPage } = pagePathUtils;
 
 
@@ -36,109 +34,132 @@ const ContentLinkButtons = dynamic(() => import('../ContentLinkButtons'), { ssr:
 const NotFoundPage = dynamic(() => import('../NotFoundPage'), { ssr: false });
 const UserInfo = dynamic<UserInfoProps>(() => import('../User/UserInfo').then(mod => mod.UserInfo), { ssr: false });
 
-const DisplaySwitcher = React.memo((): JSX.Element => {
-  const { t } = useTranslation();
 
-  // get element for smoothScroll
-  // const getCommentListDom = useMemo(() => { return document.getElementById('page-comments-list') }, []);
+const PageView = React.memo((): JSX.Element => {
+  const { t } = useTranslation();
 
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: shareLinkId } = useShareLinkId();
   const { data: isUserPage } = useIsUserPage();
-  const { data: isEditable } = useIsEditable();
   const { data: pageUser } = usePageUser();
   const { data: isNotFound } = useIsNotFound();
-  const { data: isNotCreatable } = useIsNotCreatable();
   const { data: currentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
-
-  const { data: editorMode } = useEditorMode();
-
   const { open: openDescendantPageListModal } = useDescendantsPageListModal();
 
-  const isViewMode = editorMode === EditorMode.View;
   const isTopPagePath = isTopPage(currentPagePath ?? '');
 
-  const revision = currentPage?.revision;
-
   return (
-    <>
-      <TabContent activeTab={editorMode}>
-        <TabPane tabId={EditorMode.View}>
-          <div className="d-flex flex-column flex-lg-row">
-
-            <div className="flex-grow-1 flex-basis-0 mw-0">
-              { isUserPage && pageUser != null && <UserInfo pageUser={pageUser} />}
-              { !isNotFound && <Page /> }
-              { isNotFound && <NotFoundPage /> }
-            </div>
-
-            { !isNotFound && (
-              <div className="grw-side-contents-container">
-                <div className="grw-side-contents-sticky-container">
-
-                  {/* Page list */}
-                  <div className={`grw-page-accessories-control ${styles['grw-page-accessories-control']}`}>
-                    { currentPagePath != null && !isSharedUser && (
-                      <button
-                        type="button"
-                        className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
-                        onClick={() => openDescendantPageListModal(currentPagePath)}
-                        data-testid="pageListButton"
-                      >
-                        <div className="grw-page-accessories-control-icon">
-                          <PageListIcon />
-                        </div>
-                        {t('page_list')}
-                        <CountBadge count={currentPage?.descendantCount} offset={1} />
-                      </button>
-                    ) }
-                  </div>
-
-                  {/* Comments */}
-                  {/* { getCommentListDom != null && !isTopPagePath && ( */}
-                  { !isTopPagePath && (
-                    <div className={`mt-2 grw-page-accessories-control ${styles['grw-page-accessories-control']}`}>
-                      <button
-                        type="button"
-                        className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
-                        // onClick={() => smoothScrollIntoView(getCommentListDom, WIKI_HEADER_LINK)}
-                      >
-                        <i className="icon-fw icon-bubbles grw-page-accessories-control-icon"></i>
-                        <span>Comments</span>
-                        <CountBadge count={currentPage?.commentCount} />
-                      </button>
-                    </div>
-                  ) }
-
-                  <div className="d-none d-lg-block">
-                    <TableOfContents />
-                    <ContentLinkButtons />
+    <div className="d-flex flex-column flex-lg-row">
+
+      <div className="flex-grow-1 flex-basis-0 mw-0">
+        { isUserPage && pageUser != null && <UserInfo pageUser={pageUser} />}
+        { !isNotFound && <Page /> }
+        { isNotFound && <NotFoundPage /> }
+      </div>
+
+      { !isNotFound && (
+        <div className="grw-side-contents-container">
+          <div className="grw-side-contents-sticky-container">
+
+            {/* Page list */}
+            <div className={`grw-page-accessories-control ${styles['grw-page-accessories-control']}`}>
+              { currentPagePath != null && !isSharedUser && (
+                <button
+                  type="button"
+                  className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
+                  onClick={() => openDescendantPageListModal(currentPagePath)}
+                  data-testid="pageListButton"
+                >
+                  <div className="grw-page-accessories-control-icon">
+                    <PageListIcon />
                   </div>
+                  {t('page_list')}
+                  <CountBadge count={currentPage?.descendantCount} offset={1} />
+                </button>
+              ) }
+            </div>
 
-                </div>
+            {/* Comments */}
+            {/* { getCommentListDom != null && !isTopPagePath && ( */}
+            { !isTopPagePath && (
+              <div className={`mt-2 grw-page-accessories-control ${styles['grw-page-accessories-control']}`}>
+                <button
+                  type="button"
+                  className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
+                  // onClick={() => smoothScrollIntoView(getCommentListDom, WIKI_HEADER_LINK)}
+                >
+                  <i className="icon-fw icon-bubbles grw-page-accessories-control-icon"></i>
+                  <span>Comments</span>
+                  <CountBadge count={currentPage?.commentCount} />
+                </button>
               </div>
             ) }
 
-          </div>
-        </TabPane>
-        { isEditable && (
-          <TabPane tabId={EditorMode.Editor}>
-            <div data-testid="page-editor" id="page-editor">
-              <PageEditor />
-            </div>
-          </TabPane>
-        ) }
-        { isEditable && (
-          <TabPane tabId={EditorMode.HackMD}>
-            <div id="page-editor-with-hackmd">
-              {/* <PageEditorByHackmd /> */}
+            <div className="d-none d-lg-block">
+              <TableOfContents />
+              <ContentLinkButtons />
             </div>
-          </TabPane>
-        ) }
-      </TabContent>
-      { isEditable && !isViewMode && <EditorNavbarBottom /> }
 
+          </div>
+        </div>
+      ) }
+    </div>
+  );
+});
+PageView.displayName = 'PageView';
+
+
+const DisplaySwitcher = React.memo((): JSX.Element => {
+  // get element for smoothScroll
+  // const getCommentListDom = useMemo(() => { return document.getElementById('page-comments-list') }, []);
+
+  const { data: isEditable } = useIsEditable();
+
+  const { data: editorMode = EditorMode.View } = useEditorMode();
+
+  const isViewMode = editorMode === EditorMode.View;
+
+  const navTabMapping = useMemo(() => {
+    return {
+      [EditorMode.View]: {
+        Content: () => (
+          <div data-testid="page-view" id="page-view">
+            <PageView />
+          </div>
+        ),
+      },
+      [EditorMode.Editor]: {
+        Content: () => (
+          isEditable
+            ? (
+              <div data-testid="page-editor" id="page-editor">
+                <PageEditor />
+              </div>
+            )
+            : <></>
+        ),
+      },
+      [EditorMode.HackMD]: {
+        Content: () => (
+          isEditable
+            ? (
+              <div id="page-editor-with-hackmd">
+                {/* <PageEditorByHackmd /> */}
+              </div>
+            )
+            : <></>
+        ),
+      },
+    };
+  }, [isEditable]);
+
+
+  return (
+    <>
+      <CustomTabContent activeTab={editorMode} navTabMapping={navTabMapping} />
+
+      { isEditable && !isViewMode && <EditorNavbarBottom /> }
       { isEditable && <HashChanged></HashChanged> }
     </>
   );

+ 1 - 0
packages/app/src/components/Page/RevisionRenderer.tsx

@@ -98,6 +98,7 @@ const RevisionRenderer = React.memo((props: Props): JSX.Element => {
 
   return (
     <ReactMarkdown
+      data-testid="wiki"
       {...rendererOptions}
       className={`wiki ${additionalClassName ?? ''}`}
     >

+ 5 - 3
packages/app/src/components/PageAlert/TrashPageAlert.tsx

@@ -34,13 +34,15 @@ export const TrashPageAlert = (): JSX.Element => {
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openPutBackPageModal } = usePutBackPageModal();
 
+  if (!isTrashPage) {
+    return <></>;
+  }
+
+
   const lastUpdateUserName = pageData?.lastUpdateUser?.name;
   const deletedAt = pageData?.deletedAt ? format(new Date(pageData?.deletedAt), 'yyyy/MM/dd HH:mm') : '';
   const revisionId = pageData?.revision?._id;
 
-  if (!isTrashPage) {
-    return <></>;
-  }
 
   function openPutbackPageModalHandler() {
     if (pageId === undefined || pagePath === undefined) {

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

@@ -9,7 +9,6 @@ import { toastError } from '~/client/util/apiNotification';
 import { apiPost } from '~/client/util/apiv1-client';
 import { useCurrentPagePath } from '~/stores/context';
 import { useSWRxCurrentPage } from '~/stores/page';
-import { useCommentPreviewOptions } from '~/stores/renderer';
 
 import { ICommentHasId, ICommentHasIdList } from '../interfaces/comment';
 import { useSWRxPageComment } from '../stores/comment';
@@ -29,7 +28,7 @@ const DeleteCommentModal = dynamic<DeleteCommentModalProps>(
 
 
 type PageCommentProps = {
-  pageId?: string,
+  pageId: string,
   isReadOnly: boolean,
   titleAlign?: 'center' | 'left' | 'right',
   highlightKeywords?: string[],
@@ -43,7 +42,6 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
   } = props;
 
   const { data: comments, mutate } = useSWRxPageComment(pageId);
-  const { data: rendererOptions } = useCommentPreviewOptions();
   const { data: currentPage } = useSWRxCurrentPage();
   const { data: currentPagePath } = useCurrentPagePath();
 
@@ -132,7 +130,7 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
   let commentTitleClasses = 'border-bottom py-3 mb-3';
   commentTitleClasses = titleAlign != null ? `${commentTitleClasses} text-${titleAlign}` : `${commentTitleClasses} text-center`;
 
-  if (commentsFromOldest == null || commentsExceptReply == null || rendererOptions == null || currentPagePath == null || currentPage == null) {
+  if (commentsFromOldest == null || commentsExceptReply == null || currentPagePath == null || currentPage == null) {
     if (hideIfEmpty && comments?.length === 0) {
       return <></>;
     }
@@ -151,7 +149,6 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
       isReadOnly={isReadOnly}
       deleteBtnClicked={onClickDeleteButton}
       onComment={mutate}
-      rendererOptions={rendererOptions}
       currentPagePath={currentPagePath}
       currentRevisionId={currentPage.revision._id}
       currentRevisionCreatedAt={currentPage.revision.createdAt}
@@ -164,7 +161,6 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
       replyList={replyComments}
       deleteBtnClicked={onClickDeleteButton}
       onComment={mutate}
-      rendererOptions={rendererOptions}
       currentPagePath={currentPagePath}
       currentRevisionId={currentPage.revision._id}
       currentRevisionCreatedAt={currentPage.revision.createdAt}
@@ -207,7 +203,7 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
                     )}
                     {(!isReadOnly && showEditorIds.has(comment._id)) && (
                       <CommentEditor
-                        rendererOptions={rendererOptions}
+                        pageId={pageId}
                         replyTo={comment._id}
                         onCancelButtonClicked={() => {
                           removeShowEditorId(comment._id);

+ 21 - 16
packages/app/src/components/PageComment/Comment.tsx

@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from 'react';
+import React, { useEffect, useMemo, useState } from 'react';
 
 import { UserPicture } from '@growi/ui';
 import { format } from 'date-fns';
@@ -6,8 +6,8 @@ import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import { UncontrolledTooltip } from 'reactstrap';
 
-import { RendererOptions } from '~/services/renderer/renderer';
 import { useCurrentUser } from '~/stores/context';
+import { useCommentPreviewOptions } from '~/stores/renderer';
 
 import { ICommentHasId } from '../../interfaces/comment';
 import FormattedDistanceDate from '../FormattedDistanceDate';
@@ -28,7 +28,6 @@ type CommentProps = {
   isReadOnly: boolean,
   deleteBtnClicked: (comment: ICommentHasId) => void,
   onComment: () => void,
-  rendererOptions: RendererOptions,
   currentPagePath: string,
   currentRevisionId: string,
   currentRevisionCreatedAt: Date,
@@ -37,12 +36,13 @@ type CommentProps = {
 export const Comment = (props: CommentProps): JSX.Element => {
 
   const {
-    comment, isReadOnly, deleteBtnClicked, onComment, rendererOptions,
+    comment, isReadOnly, deleteBtnClicked, onComment,
     currentPagePath, currentRevisionId, currentRevisionCreatedAt,
   } = props;
 
   const { t } = useTranslation();
   const { data: currentUser } = useCurrentUser();
+  const { data: rendererOptions } = useCommentPreviewOptions();
 
   const [markdown, setMarkdown] = useState('');
   const [isReEdit, setIsReEdit] = useState(false);
@@ -101,19 +101,24 @@ export const Comment = (props: CommentProps): JSX.Element => {
     return <span style={{ whiteSpace: 'pre-wrap' }}>{comment}</span>;
   };
 
-  const renderRevisionBody = () => {
-    return (
-      <RevisionRenderer
-        rendererOptions={rendererOptions}
-        markdown={markdown}
-        additionalClassName="comment"
-        pagePath={currentPagePath}
-      />
-    );
-  };
+  const commentBody = useMemo(() => {
+    if (rendererOptions == null) {
+      return <></>;
+    }
+
+    return isMarkdown
+      ? (
+        <RevisionRenderer
+          rendererOptions={rendererOptions}
+          markdown={markdown}
+          additionalClassName="comment"
+          pagePath={currentPagePath}
+        />
+      )
+      : renderText(comment.comment);
+  }, [comment, currentPagePath, isMarkdown, markdown, rendererOptions]);
 
   const rootClassName = getRootClassName(comment);
-  const commentBody = isMarkdown ? renderRevisionBody() : renderText(comment.comment);
   const revHref = `?revision=${comment.revision}`;
 
   const editedDateId = `editedDate-${comment._id}`;
@@ -125,7 +130,7 @@ export const Comment = (props: CommentProps): JSX.Element => {
     <div className={`${styles['comment-styles']}`}>
       {(isReEdit && !isReadOnly) ? (
         <CommentEditor
-          rendererOptions={rendererOptions}
+          pageId={comment._id}
           replyTo={undefined}
           currentCommentId={commentId}
           commentBody={comment.comment}

+ 6 - 10
packages/app/src/components/PageComment/CommentEditor.module.scss

@@ -1,5 +1,6 @@
 @use '~/styles/bootstrap/init' as bs;
-@use './_comment-inheritance';
+@use './comment-inheritance';
+@use '../PageEditor/page-editor-inheritance';
 
 // display cheatsheet for comment form only
 .comment-editor-styles :global {
@@ -30,14 +31,9 @@
     }
   }
 
-  // TODO: Refacotr Soft-coding
-  .page-comment-commenteditorlazyrenderer-body-skelton {
-    position: relative;
-    padding: 2.258rem 2rem;
-    margin-left: 4.5em;
-    line-height: 1.5;
-    @include bs.media-breakpoint-down(xs) {
-      margin-left: 3.5em;
-    }
+  .page-comment-editor-skelton {
+    height: comment-inheritance.$codemirror-default-height;
+    margin-top: page-editor-inheritance.$navbar-editor-height;
+    margin-bottom: bs.$line-height-base + bs.$btn-padding-y;
   }
 }

+ 19 - 18
packages/app/src/components/PageComment/CommentEditor.tsx

@@ -3,30 +3,39 @@ import React, {
 } from 'react';
 
 import { UserPicture } from '@growi/ui';
+import dynamic from 'next/dynamic';
 import {
   Button, TabContent, TabPane,
 } from 'reactstrap';
 import * as toastr from 'toastr';
 
 import { apiPostForm } from '~/client/util/apiv1-client';
-import { RendererOptions } from '~/services/renderer/renderer';
+import { IEditorMethods } from '~/interfaces/editor-methods';
 import { useSWRxPageComment } from '~/stores/comment';
 import {
-  useCurrentPagePath, useCurrentPageId, useCurrentUser, useRevisionId, useIsSlackConfigured,
+  useCurrentPagePath, useCurrentUser, useRevisionId, useIsSlackConfigured,
   useIsUploadableFile, useIsUploadableImage,
 } from '~/stores/context';
 import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
 
 import { CustomNavTab } from '../CustomNavigation/CustomNav';
 import NotAvailableForGuest from '../NotAvailableForGuest';
-import Editor from '../PageEditor/Editor';
-import { SlackNotification } from '../SlackNotification';
+import { Skelton } from '../Skelton';
+
 
 import { CommentPreview } from './CommentPreview';
 
 import styles from './CommentEditor.module.scss';
 
 
+const SlackNotification = dynamic(() => import('../SlackNotification').then(mod => mod.SlackNotification), { ssr: false });
+const Editor = dynamic(() => import('../PageEditor/Editor'),
+  {
+    ssr: false,
+    loading: () => <Skelton additionalClass="grw-skelton page-comment-editor-skelton" />,
+  });
+
+
 const navTabMapping = {
   comment_editor: {
     Icon: () => <i className="icon-settings" />,
@@ -41,7 +50,7 @@ const navTabMapping = {
 };
 
 export type CommentEditorProps = {
-  rendererOptions: RendererOptions,
+  pageId: string,
   isForNewComment?: boolean,
   replyTo?: string,
   currentCommentId?: string,
@@ -50,23 +59,17 @@ export type CommentEditorProps = {
   onCommentButtonClicked?: () => void,
 }
 
-type EditorRef = {
-  setValue: (value: string) => void,
-  insertText: (text: string) => void,
-  terminateUploadingState: () => void,
-}
 
 export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
 
   const {
-    rendererOptions, isForNewComment, replyTo,
+    pageId, isForNewComment, replyTo,
     currentCommentId, commentBody, onCancelButtonClicked, onCommentButtonClicked,
   } = props;
 
   const { data: currentUser } = useCurrentUser();
   const { data: currentPagePath } = useCurrentPagePath();
-  const { data: currentPageId } = useCurrentPageId();
-  const { update: updateComment, post: postComment } = useSWRxPageComment(currentPageId);
+  const { update: updateComment, post: postComment } = useSWRxPageComment(pageId);
   const { data: revisionId } = useRevisionId();
   const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
@@ -80,7 +83,7 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
   const [error, setError] = useState();
   const [slackChannels, setSlackChannels] = useState(slackChannelsData?.toString());
 
-  const editorRef = useRef<EditorRef>(null);
+  const editorRef = useRef<IEditorMethods>(null);
 
   const handleSelect = useCallback((activeTab: string) => {
     setActiveTab(activeTab);
@@ -173,7 +176,6 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
     if (editorRef.current == null) { return }
 
     const pagePath = currentPagePath;
-    const pageId = currentPageId;
     const endpoint = '/attachments.add';
     const formData = new FormData();
     formData.append('file', file);
@@ -199,7 +201,7 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
     finally {
       editorRef.current.terminateUploadingState();
     }
-  }, [apiErrorHandler, currentPageId, currentPagePath]);
+  }, [apiErrorHandler, currentPagePath, pageId]);
 
   const getCommentHtml = useCallback(() => {
     if (currentPagePath == null) {
@@ -208,12 +210,11 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
 
     return (
       <CommentPreview
-        rendererOptions={rendererOptions}
         markdown={comment}
         path={currentPagePath}
       />
     );
-  }, [currentPagePath, comment, rendererOptions]);
+  }, [currentPagePath, comment]);
 
   const renderBeforeReady = useCallback((): JSX.Element => {
     return (

+ 0 - 47
packages/app/src/components/PageComment/CommentEditorLazyRenderer.tsx

@@ -1,47 +0,0 @@
-import React from 'react';
-
-import dynamic from 'next/dynamic';
-
-import { RendererOptions } from '~/services/renderer/renderer';
-
-import { useSWRxPageComment } from '../../stores/comment';
-import { Skelton } from '../Skelton';
-
-import { CommentEditorProps } from './CommentEditor';
-
-import CommentEditorStyles from './CommentEditor.module.scss';
-
-const CommentEditor = dynamic<CommentEditorProps>(() => import('./CommentEditor').then(mod => mod.CommentEditor),
-  {
-    ssr: false,
-    loading: () => <div className={`${CommentEditorStyles['comment-editor-styles']} form page-comment-form`}>
-      <div className='comment-form'>
-        <div className='comment-form-user'>
-          <Skelton additionalClass='rounded-circle picture' roundedPill />
-        </div>
-        <Skelton additionalClass="page-comment-commenteditorlazyrenderer-body-skelton grw-skelton" />
-      </div>
-    </div>,
-  });
-
-type Props = {
-  pageId?: string,
-  rendererOptions: RendererOptions,
-}
-
-export const CommentEditorLazyRenderer = (props: Props): JSX.Element => {
-
-  const { pageId, rendererOptions } = props;
-
-  const { mutate } = useSWRxPageComment(pageId);
-
-  return (
-    <CommentEditor
-      rendererOptions={rendererOptions}
-      isForNewComment
-      replyTo={undefined}
-      onCommentButtonClicked={mutate}
-    />
-  );
-
-};

+ 9 - 0
packages/app/src/components/PageComment/CommentPreview.module.scss

@@ -0,0 +1,9 @@
+@use '~/styles/bootstrap/init' as bs;
+@use './comment-inheritance';
+@use '../PageEditor/page-editor-inheritance';
+
+.grw-comment-preview {
+  min-height: page-editor-inheritance.$navbar-editor-height
+    + comment-inheritance.$codemirror-default-height
+    + bs.$line-height-base;
+}

+ 12 - 4
packages/app/src/components/PageComment/CommentPreview.tsx

@@ -1,20 +1,28 @@
-import { RendererOptions } from '~/services/renderer/renderer';
+import { useCommentPreviewOptions } from '~/stores/renderer';
 
 import RevisionRenderer from '../Page/RevisionRenderer';
 
 
+import styles from './CommentPreview.module.scss';
+
+
 type CommentPreviewPorps = {
-  rendererOptions: RendererOptions,
   markdown: string,
   path: string,
 }
 
 export const CommentPreview = (props: CommentPreviewPorps): JSX.Element => {
 
-  const { rendererOptions, markdown, path } = props;
+  const { markdown, path } = props;
+
+  const { data: rendererOptions } = useCommentPreviewOptions();
+
+  if (rendererOptions == null) {
+    return <></>;
+  }
 
   return (
-    <div className="page-comment-preview-body">
+    <div className={`grw-comment-preview ${styles['grw-comment-preview']}`}>
       <RevisionRenderer
         rendererOptions={rendererOptions}
         markdown={markdown}

+ 1 - 5
packages/app/src/components/PageComment/ReplyComments.tsx

@@ -3,8 +3,6 @@ import React, { useState } from 'react';
 
 import { Collapse } from 'reactstrap';
 
-import { RendererOptions } from '~/services/renderer/renderer';
-
 import { ICommentHasId, ICommentHasIdList } from '../../interfaces/comment';
 import { useIsAllReplyShown } from '../../stores/context';
 
@@ -18,7 +16,6 @@ type ReplycommentsProps = {
   replyList: ICommentHasIdList,
   deleteBtnClicked: (comment: ICommentHasId) => void,
   onComment: () => void,
-  rendererOptions: RendererOptions,
   currentPagePath: string,
   currentRevisionId: string,
   currentRevisionCreatedAt: Date,
@@ -27,7 +24,7 @@ type ReplycommentsProps = {
 export const ReplyComments = (props: ReplycommentsProps): JSX.Element => {
 
   const {
-    isReadOnly, replyList, deleteBtnClicked, onComment, rendererOptions,
+    isReadOnly, replyList, deleteBtnClicked, onComment,
     currentPagePath, currentRevisionId, currentRevisionCreatedAt,
   } = props;
 
@@ -43,7 +40,6 @@ export const ReplyComments = (props: ReplycommentsProps): JSX.Element => {
           isReadOnly={isReadOnly}
           deleteBtnClicked={deleteBtnClicked}
           onComment={onComment}
-          rendererOptions={rendererOptions}
           currentPagePath={currentPagePath}
           currentRevisionId={currentRevisionId}
           currentRevisionCreatedAt={currentRevisionCreatedAt}

+ 2 - 0
packages/app/src/components/PageComment/_comment-inheritance.scss

@@ -32,3 +32,5 @@
     height: 2em;
   }
 }
+
+$codemirror-default-height: 300px;

+ 9 - 17
packages/app/src/components/PageEditor.tsx

@@ -11,6 +11,7 @@ import { throttle, debounce } from 'throttle-debounce';
 import { saveOrUpdate } from '~/client/services/page-operation';
 import { apiGet, apiPostForm } from '~/client/util/apiv1-client';
 import { getOptionsToSave } from '~/client/util/editor';
+import { IEditorMethods } from '~/interfaces/editor-methods';
 import {
   useIsEditable, useIsIndentSizeForced, useCurrentPagePath, useCurrentPathname, useCurrentPageId, useIsUploadableFile, useIsUploadableImage,
 } from '~/stores/context';
@@ -39,14 +40,6 @@ const logger = loggerFactory('growi:PageEditor');
 declare const globalEmitter: EventEmitter;
 
 
-type EditorRef = {
-  setValue: (markdown: string) => void,
-  setCaretLine: (line: number) => void,
-  insertText: (text: string) => void,
-  forceToFocus: () => void,
-  terminateUploadingState: () => void,
-}
-
 // for scrolling
 let lastScrolledDateWithCursor: Date | null = null;
 let isOriginOfScrollSyncEditor = false;
@@ -69,7 +62,7 @@ const PageEditor = React.memo((): JSX.Element => {
   const { data: isTextlintEnabled } = useIsTextlintEnabled();
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
   const { data: indentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
-  const { data: isEnabledUnsavedWarning, mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
+  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const { data: isUploadableFile } = useIsUploadableFile();
   const { data: isUploadableImage } = useIsUploadableImage();
 
@@ -84,21 +77,20 @@ const PageEditor = React.memo((): JSX.Element => {
   const slackChannels = useMemo(() => (slackChannelsData ? slackChannelsData.toString() : ''), [slackChannelsData]);
 
 
-  const editorRef = useRef<EditorRef>(null);
+  const editorRef = useRef<IEditorMethods>(null);
   const previewRef = useRef<HTMLDivElement>(null);
 
-  const setMarkdownWithDebounce = useMemo(() => debounce(50, throttle(100, (value) => {
+  const setMarkdownWithDebounce = useMemo(() => debounce(100, throttle(150, (value: string, isClean: boolean) => {
     markdownToSave.current = value;
     setMarkdownToPreview(value);
+
     // Displays an unsaved warning alert
-    if (!isEnabledUnsavedWarning) {
-      mutateIsEnabledUnsavedWarning(true);
-    }
-  })), [isEnabledUnsavedWarning, mutateIsEnabledUnsavedWarning]);
+    mutateIsEnabledUnsavedWarning(!isClean);
+  })), [mutateIsEnabledUnsavedWarning]);
 
 
-  const markdownChangedHandler = useCallback((value: string): void => {
-    setMarkdownWithDebounce(value);
+  const markdownChangedHandler = useCallback((value: string, isClean: boolean): void => {
+    setMarkdownWithDebounce(value, isClean);
   }, [setMarkdownWithDebounce]);
 
   const save = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}) => {

+ 8 - 1
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -168,6 +168,9 @@ class CodeMirrorEditor extends AbstractEditor {
     // ensure to be able to resolve 'this' to use 'codemirror.commands.save'
     this.getCodeMirror().codeMirrorEditor = this;
 
+    // mark clean
+    this.getCodeMirror().getDoc().markClean();
+
     // fold drawio section
     this.foldDrawioSection();
 
@@ -244,6 +247,9 @@ class CodeMirrorEditor extends AbstractEditor {
    */
   setValue(newValue) {
     this.getCodeMirror().getDoc().setValue(newValue);
+
+    // mark clean
+    this.getCodeMirror().getDoc().markClean();
   }
 
   /**
@@ -564,7 +570,8 @@ class CodeMirrorEditor extends AbstractEditor {
 
   changeHandler(editor, data, value) {
     if (this.props.onChange != null) {
-      this.props.onChange(value);
+      const isClean = data.origin == null || editor.isClean();
+      this.props.onChange(value, isClean);
     }
 
     this.updateCheatsheetStates(null, value);

+ 2 - 1
packages/app/src/components/PageEditor/Editor.module.scss

@@ -1,5 +1,6 @@
 @use '~/styles/mixins' as ms;
 @use '~/styles/bootstrap/init' as bs;
+@use './page-editor-inheritance';
 
 
 .editor-container :global {
@@ -127,7 +128,7 @@
 
   // for Navbar editor
   .navbar-editor {
-    height: 30px;
+    height: page-editor-inheritance.$navbar-editor-height;
     padding: 0;
 
     border-bottom: 1px solid transparent;

+ 28 - 46
packages/app/src/components/PageEditor/Editor.tsx

@@ -1,5 +1,5 @@
 import React, {
-  useState, useRef, useImperativeHandle, useCallback, useMemo,
+  useState, useRef, useImperativeHandle, useCallback, ForwardRefRenderFunction, forwardRef,
 } from 'react';
 
 import Dropzone from 'react-dropzone';
@@ -22,14 +22,14 @@ import TextAreaEditor from './TextAreaEditor';
 
 import styles from './Editor.module.scss';
 
-type EditorPropsType = {
+export type EditorPropsType = {
   value?: string,
   isGfmMode?: boolean,
   noCdn?: boolean,
   isUploadable?: boolean,
   isUploadableFile?: boolean,
   isTextlintEnabled?: boolean,
-  onChange?: (newValue: string) => void,
+  onChange?: (newValue: string, isClean?: boolean) => void,
   onUpload?: (file) => void,
   indentSize?: number,
   onScroll?: ({ line: number }) => void,
@@ -44,7 +44,7 @@ type DropzoneRef = {
   open: () => void
 }
 
-const Editor = React.forwardRef((props: EditorPropsType, ref): JSX.Element => {
+const Editor: ForwardRefRenderFunction<IEditorMethods, EditorPropsType> = (props, ref): JSX.Element => {
   const {
     onUpload, isUploadable, isUploadableFile, indentSize, isGfmMode = true,
   } = props;
@@ -66,45 +66,29 @@ const Editor = React.forwardRef((props: EditorPropsType, ref): JSX.Element => {
     return isMobile ? taEditorRef.current : cmEditorRef.current;
   }, [isMobile]);
 
-  const methods: Partial<IEditorMethods> = useMemo(() => {
-    return {
-      forceToFocus: () => {
-        editorSubstance()?.forceToFocus();
-      },
-      setValue: (newValue: string) => {
-        editorSubstance()?.setValue(newValue);
-      },
-      setGfmMode: (bool: boolean) => {
-        editorSubstance()?.setGfmMode(bool);
-      },
-      setCaretLine: (line: number) => {
-        editorSubstance()?.setCaretLine(line);
-      },
-      setScrollTopByLine: (line: number) => {
-        editorSubstance()?.setScrollTopByLine(line);
-      },
-      insertText: (text: string) => {
-        editorSubstance()?.insertText(text);
-      },
-      getNavbarItems: (): JSX.Element[] => {
-        // concat common items and items specific to CodeMirrorEditor or TextAreaEditor
-        const navbarItems = editorSubstance()?.getNavbarItems() ?? [];
-        return navbarItems;
-      },
-    };
-  }, [editorSubstance]);
-
   // methods for ref
   useImperativeHandle(ref, () => ({
-    forceToFocus: methods.forceToFocus,
-    setValue: methods.setValue,
-    setGfmMode: methods.setGfmMode,
-    setCaretLine: methods.setCaretLine,
-    setScrollTopByLine: methods.setScrollTopByLine,
-    insertText: methods.insertText,
+    forceToFocus: () => {
+      editorSubstance()?.forceToFocus();
+    },
+    setValue: (newValue: string) => {
+      editorSubstance()?.setValue(newValue);
+    },
+    setGfmMode: (bool: boolean) => {
+      editorSubstance()?.setGfmMode(bool);
+    },
+    setCaretLine: (line: number) => {
+      editorSubstance()?.setCaretLine(line);
+    },
+    setScrollTopByLine: (line: number) => {
+      editorSubstance()?.setScrollTopByLine(line);
+    },
+    insertText: (text: string) => {
+      editorSubstance()?.insertText(text);
+    },
     /**
-   * remove overlay and set isUploading to false
-   */
+     * remove overlay and set isUploading to false
+     */
     terminateUploadingState: () => {
       setDropzoneActive(false);
       setIsUploading(false);
@@ -239,14 +223,14 @@ const Editor = React.forwardRef((props: EditorPropsType, ref): JSX.Element => {
     return (
       <div className="m-0 navbar navbar-default navbar-editor" style={{ minHeight: 'unset' }}>
         <ul className="pl-2 nav nav-navbar">
-          { methods.getNavbarItems?.().map((item, idx) => {
+          { (editorSubstance()?.getNavbarItems() ?? []).map((item, idx) => {
             // eslint-disable-next-line react/no-array-index-key
             return <li key={`navbarItem-${idx}`}>{item}</li>;
           }) }
         </ul>
       </div>
     );
-  }, [methods]);
+  }, [editorSubstance]);
 
   const renderCheatsheetModal = useCallback(() => {
     const hideCheatsheetModal = () => {
@@ -355,8 +339,6 @@ const Editor = React.forwardRef((props: EditorPropsType, ref): JSX.Element => {
       </div>
     </>
   );
-});
-
-Editor.displayName = 'Editor';
+};
 
-export default Editor;
+export default forwardRef(Editor);

+ 1 - 0
packages/app/src/components/PageEditor/_page-editor-inheritance.scss

@@ -0,0 +1 @@
+$navbar-editor-height: 30px;

+ 8 - 5
packages/app/src/components/PagePathNav.tsx

@@ -1,6 +1,6 @@
 import React, { FC } from 'react';
 
-import { DevidedPagePath } from '@growi/core';
+import { DevidedPagePath, pagePathUtils } from '@growi/core';
 import dynamic from 'next/dynamic';
 
 import { useIsNotFound } from '~/stores/context';
@@ -9,6 +9,7 @@ import LinkedPagePath from '../models/linked-page-path';
 
 import PagePathHierarchicalLink from './PagePathHierarchicalLink';
 
+const { isTrashPage } = pagePathUtils;
 
 type Props = {
   pagePath: string,
@@ -17,6 +18,8 @@ type Props = {
   isCompactMode?:boolean,
 }
 
+const CopyDropdown = dynamic(() => import('./Page/CopyDropdown'), { ssr: false });
+
 const PagePathNav: FC<Props> = (props: Props) => {
   const {
     pageId, pagePath, isSingleLineMode, isCompactMode,
@@ -25,7 +28,7 @@ const PagePathNav: FC<Props> = (props: Props) => {
 
   const { data: isNotFound } = useIsNotFound();
 
-  const CopyDropdown = dynamic(() => import('./Page/CopyDropdown'), { ssr: false });
+  const isInTrash = isTrashPage(pagePath);
 
   let formerLink;
   let latterLink;
@@ -33,14 +36,14 @@ const PagePathNav: FC<Props> = (props: Props) => {
   // one line
   if (dPagePath.isRoot || dPagePath.isFormerRoot || isSingleLineMode) {
     const linkedPagePath = new LinkedPagePath(pagePath);
-    latterLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePath} />;
+    latterLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePath} isInTrash={isInTrash} />;
   }
   // two line
   else {
     const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
     const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
-    formerLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} />;
-    latterLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.former} />;
+    formerLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} isInTrash={isInTrash} />;
+    latterLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.former} isInTrash={isInTrash} />;
   }
 
   const copyDropdownId = `copydropdown${isCompactMode ? '-subnav-compact' : ''}-${pageId}`;

+ 10 - 4
packages/app/src/components/PageStatusAlert.jsx

@@ -1,10 +1,11 @@
 import React from 'react';
 
-import PropTypes from 'prop-types';
 import { useTranslation } from 'next-i18next';
+import PropTypes from 'prop-types';
 
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
+import Username from '~/components/User/Username';
 
 import { withUnstatedContainers } from './UnstatedUtils';
 
@@ -82,9 +83,14 @@ class PageStatusAlert extends React.Component {
       isConflictOnEdit = markdownOnEdit !== pageContainer.state.markdown;
     }
 
-    const label1 = isConflictOnEdit
-      ? t('modal_resolve_conflict.file_conflicting_with_newer_remote')
-      : `${pageContainer.state.lastUpdateUsername} ${t('edited this page')}`;
+    // TODO: re-impl with Next.js way
+    // const usernameComponentToString = ReactDOMServer.renderToString(<Username user={pageContainer.state.lastUpdateUser} />);
+
+    // const label1 = isConflictOnEdit
+    //   ? t('modal_resolve_conflict.file_conflicting_with_newer_remote')
+    //   // eslint-disable-next-line react/no-danger
+    //   : <span dangerouslySetInnerHTML={{ __html: `${usernameComponentToString} ${t('edited this page')}` }} />;
+    const label1 = '(TBD -- 2022.09.13)';
 
     return [
       ['bg-warning'],

+ 1 - 1
packages/app/src/components/ShareLink/ShareLink.tsx

@@ -9,7 +9,7 @@ import { apiv3Delete } from '~/client/util/apiv3-client';
 import { useCurrentPageId } from '~/stores/context';
 import { useSWRxSharelink } from '~/stores/share-link';
 
-import ShareLinkForm from './ShareLinkForm';
+import { ShareLinkForm } from './ShareLinkForm';
 import ShareLinkList from './ShareLinkList';
 
 const ShareLink = (): JSX.Element => {

+ 0 - 277
packages/app/src/components/ShareLink/ShareLinkForm.jsx

@@ -1,277 +0,0 @@
-import React from 'react';
-
-import { isInteger } from 'core-js/fn/number';
-import { format, parse } from 'date-fns';
-import PropTypes from 'prop-types';
-import { useTranslation } from 'next-i18next';
-
-import PageContainer from '~/client/services/PageContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { apiv3Post } from '~/client/util/apiv3-client';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-
-class ShareLinkForm extends React.Component {
-
-  constructor(props) {
-    super(props);
-    this.state = {
-      expirationType: 'unlimited',
-      numberOfDays: '7',
-      description: '',
-      customExpirationDate: format(new Date(), 'yyyy-MM-dd'),
-      customExpirationTime: format(new Date(), 'HH:mm'),
-    };
-
-    this.handleChangeExpirationType = this.handleChangeExpirationType.bind(this);
-    this.handleChangeNumberOfDays = this.handleChangeNumberOfDays.bind(this);
-    this.handleChangeDescription = this.handleChangeDescription.bind(this);
-    this.handleIssueShareLink = this.handleIssueShareLink.bind(this);
-  }
-
-  /**
-   * change expirationType
-   * @param {string} expirationType
-   */
-  handleChangeExpirationType(expirationType) {
-    this.setState({ expirationType });
-  }
-
-  /**
-   * change numberOfDays
-   * @param {string} numberOfDays
-   */
-  handleChangeNumberOfDays(numberOfDays) {
-    this.setState({ numberOfDays });
-  }
-
-  /**
-   * change description
-   * @param {string} description
-   */
-  handleChangeDescription(description) {
-    this.setState({ description });
-  }
-
-  /**
-   * change customExpirationDate
-   * @param {date} customExpirationDate
-   */
-  handleChangeCustomExpirationDate(customExpirationDate) {
-    this.setState({ customExpirationDate });
-  }
-
-  /**
-   * change customExpirationTime
-   * @param {date} customExpirationTime
-   */
-  handleChangeCustomExpirationTime(customExpirationTime) {
-    this.setState({ customExpirationTime });
-  }
-
-  /**
-   * Generate expiredAt by expirationType
-   */
-  generateExpired() {
-    const { t } = this.props;
-    const { expirationType } = this.state;
-    let expiredAt;
-
-    if (expirationType === 'unlimited') {
-      return null;
-    }
-
-    if (expirationType === 'numberOfDays') {
-      if (!isInteger(Number(this.state.numberOfDays))) {
-        throw new Error(t('share_links.Invalid_Number_of_Date'));
-      }
-      const date = new Date();
-      date.setDate(date.getDate() + Number(this.state.numberOfDays));
-      expiredAt = date;
-    }
-
-    if (expirationType === 'custom') {
-      const { customExpirationDate, customExpirationTime } = this.state;
-      expiredAt = parse(`${customExpirationDate}T${customExpirationTime}`, "yyyy-MM-dd'T'HH:mm", new Date());
-    }
-
-    return expiredAt;
-  }
-
-  closeForm() {
-    const { onCloseForm } = this.props;
-
-    if (onCloseForm == null) {
-      return;
-    }
-    onCloseForm();
-  }
-
-  async handleIssueShareLink() {
-    const {
-      t, pageContainer,
-    } = this.props;
-    const { pageId } = pageContainer.state;
-    const { description } = this.state;
-
-    let expiredAt;
-
-    try {
-      expiredAt = this.generateExpired();
-    }
-    catch (err) {
-      return toastError(err);
-    }
-
-    try {
-      await apiv3Post('/share-links/', { relatedPage: pageId, expiredAt, description });
-      this.closeForm();
-      toastSuccess(t('toaster.issue_share_link'));
-    }
-    catch (err) {
-      toastError(err);
-    }
-
-  }
-
-  renderExpirationTypeOptions() {
-    const { expirationType } = this.state;
-    const { t } = this.props;
-
-    return (
-      <div className="form-group row">
-        <label htmlFor="inputDesc" className="col-md-5 col-form-label">{t('share_links.expire')}</label>
-        <div className="col-md-7">
-
-
-          <div className="custom-control custom-radio form-group ">
-            <input
-              type="radio"
-              className="custom-control-input"
-              id="customRadio1"
-              name="expirationType"
-              value="customRadio1"
-              checked={expirationType === 'unlimited'}
-              onChange={() => { this.handleChangeExpirationType('unlimited') }}
-            />
-            <label className="custom-control-label" htmlFor="customRadio1">{t('share_links.Unlimited')}</label>
-          </div>
-
-          <div className="custom-control custom-radio  form-group">
-            <input
-              type="radio"
-              className="custom-control-input"
-              id="customRadio2"
-              value="customRadio2"
-              checked={expirationType === 'numberOfDays'}
-              onChange={() => { this.handleChangeExpirationType('numberOfDays') }}
-              name="expirationType"
-            />
-            <label className="custom-control-label" htmlFor="customRadio2">
-              <div className="row align-items-center m-0">
-                <input
-                  type="number"
-                  min="1"
-                  className="col-4"
-                  name="expirationType"
-                  value={this.state.numberOfDays}
-                  onFocus={() => { this.handleChangeExpirationType('numberOfDays') }}
-                  onChange={e => this.handleChangeNumberOfDays(Number(e.target.value))}
-                />
-                <span className="col-auto">{t('share_links.Days')}</span>
-              </div>
-            </label>
-          </div>
-
-          <div className="custom-control custom-radio form-group text-nowrap mb-0">
-            <input
-              type="radio"
-              className="custom-control-input"
-              id="customRadio3"
-              name="expirationType"
-              value="customRadio3"
-              checked={expirationType === 'custom'}
-              onChange={() => { this.handleChangeExpirationType('custom') }}
-            />
-            <label className="custom-control-label" htmlFor="customRadio3">
-              {t('share_links.Custom')}
-            </label>
-            <div className="d-inline-flex flex-wrap">
-              <input
-                type="date"
-                className="ml-3 mb-2"
-                name="customExpirationDate"
-                value={this.state.customExpirationDate}
-                onFocus={() => { this.handleChangeExpirationType('custom') }}
-                onChange={e => this.handleChangeCustomExpirationDate(e.target.value)}
-              />
-              <input
-                type="time"
-                className="ml-3 mb-2"
-                name="customExpiration"
-                value={this.state.customExpirationTime}
-                onFocus={() => { this.handleChangeExpirationType('custom') }}
-                onChange={e => this.handleChangeCustomExpirationTime(e.target.value)}
-              />
-            </div>
-          </div>
-        </div>
-      </div>
-    );
-  }
-
-  renderDescriptionForm() {
-    const { t } = this.props;
-    return (
-      <div className="form-group row">
-        <label htmlFor="inputDesc" className="col-md-5 col-form-label">{t('share_links.description')}</label>
-        <div className="col-md-4">
-          <input
-            type="text"
-            className="form-control"
-            id="inputDesc"
-            placeholder={t('share_links.enter_desc')}
-            value={this.state.description}
-            onChange={e => this.handleChangeDescription(e.target.value)}
-          />
-        </div>
-      </div>
-    );
-  }
-
-  render() {
-    const { t } = this.props;
-    return (
-      <div className="share-link-form p-3">
-        <h3 className="grw-modal-head pb-2"> { t('share_links.share_settings') }</h3>
-        <div className=" p-3">
-          {this.renderExpirationTypeOptions()}
-          {this.renderDescriptionForm()}
-          <button type="button" className="btn btn-primary d-block mx-auto px-5" onClick={this.handleIssueShareLink}>
-            {t('share_links.Issue')}
-          </button>
-        </div>
-      </div>
-    );
-  }
-
-}
-
-ShareLinkForm.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-  onCloseForm: PropTypes.func,
-};
-
-const ShareLinkFormWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <ShareLinkForm t={t} {...props} />;
-};
-
-/**
- * Wrapper component for using unstated
- */
-const ShareLinkFormWrapper = withUnstatedContainers(ShareLinkFormWrapperFC, [PageContainer]);
-
-export default ShareLinkFormWrapper;

+ 212 - 0
packages/app/src/components/ShareLink/ShareLinkForm.tsx

@@ -0,0 +1,212 @@
+import React, { FC, useState, useCallback } from 'react';
+
+import { isInteger } from 'core-js/fn/number';
+import { format, parse } from 'date-fns';
+import { useTranslation } from 'next-i18next';
+
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiv3Post } from '~/client/util/apiv3-client';
+import { useCurrentPageId } from '~/stores/context';
+
+
+const ExpirationType = {
+  UNLIMITED: 'unlimited',
+  CUSTOM: 'custom',
+  NUMBER_OF_DAYS: 'numberOfDays',
+} as const;
+
+type ExpirationType = typeof ExpirationType[keyof typeof ExpirationType];
+
+type Props = {
+  onCloseForm: () => void,
+}
+
+export const ShareLinkForm: FC<Props> = (props: Props) => {
+  const { t } = useTranslation();
+  const { onCloseForm } = props;
+
+  const [expirationType, setExpirationType] = useState<ExpirationType>(ExpirationType.UNLIMITED);
+  const [numberOfDays, setNumberOfDays] = useState<number>(7);
+  const [description, setDescription] = useState<string>('');
+  const [customExpirationDate, setCustomExpirationDate] = useState<Date>(new Date());
+  const [customExpirationTime, setCustomExpirationTime] = useState<Date>(new Date());
+
+  const { data: currentPageId } = useCurrentPageId();
+
+  const handleChangeExpirationType = useCallback((expirationType: ExpirationType) => {
+    setExpirationType(expirationType);
+  }, []);
+
+  const handleChangeNumberOfDays = useCallback((numberOfDays: number) => {
+    setNumberOfDays(numberOfDays);
+  }, []);
+
+  const handleChangeDescription = useCallback((description: string) => {
+    setDescription(description);
+  }, []);
+
+  const handleChangeCustomExpirationDate = useCallback((customExpirationDate: string) => {
+    const parsedDate = parse(customExpirationDate, 'yyyy-MM-dd', new Date());
+    setCustomExpirationDate(parsedDate);
+  }, []);
+
+  const handleChangeCustomExpirationTime = useCallback((customExpirationTime: string) => {
+    const parsedTime = parse(customExpirationTime, 'HH:mm', new Date());
+    setCustomExpirationTime(parsedTime);
+  }, []);
+
+  const generateExpired = useCallback(() => {
+    let expiredAt;
+
+    if (expirationType === ExpirationType.UNLIMITED) {
+      return null;
+    }
+
+    if (expirationType === ExpirationType.NUMBER_OF_DAYS) {
+      if (!isInteger(Number(numberOfDays))) {
+        throw new Error(t('share_links.Invalid_Number_of_Date'));
+      }
+      const date = new Date();
+      date.setDate(date.getDate() + Number(numberOfDays));
+      expiredAt = date;
+    }
+
+    if (expirationType === ExpirationType.CUSTOM) {
+      expiredAt = parse(`${customExpirationDate}T${customExpirationTime}`, "yyyy-MM-dd'T'HH:mm", new Date());
+    }
+
+    return expiredAt;
+  }, [t, customExpirationTime, customExpirationDate, expirationType, numberOfDays]);
+
+  const closeForm = useCallback(() => {
+    if (onCloseForm == null) {
+      return;
+    }
+    onCloseForm();
+  }, [onCloseForm]);
+
+  const handleIssueShareLink = useCallback(async() => {
+    let expiredAt;
+
+    try {
+      expiredAt = generateExpired();
+    }
+    catch (err) {
+      return toastError(err);
+    }
+
+    try {
+      await apiv3Post('/share-links/', { relatedPage: currentPageId, expiredAt, description });
+      closeForm();
+      toastSuccess(t('toaster.issue_share_link'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [t, currentPageId, description, closeForm, generateExpired]);
+
+  return (
+    <div className="share-link-form p-3">
+      <h3 className="grw-modal-head pb-2"> { t('share_links.share_settings') }</h3>
+      <div className=" p-3">
+
+        {/* ExpirationTypeOptions */}
+        <div className="form-group row">
+          <label htmlFor="inputDesc" className="col-md-5 col-form-label">{t('share_links.expire')}</label>
+          <div className="col-md-7">
+
+            <div className="custom-control custom-radio form-group ">
+              <input
+                type="radio"
+                className="custom-control-input"
+                id="customRadio1"
+                name="expirationType"
+                value="customRadio1"
+                checked={expirationType === ExpirationType.UNLIMITED}
+                onChange={() => { handleChangeExpirationType(ExpirationType.UNLIMITED) }}
+              />
+              <label className="custom-control-label" htmlFor="customRadio1">{t('share_links.Unlimited')}</label>
+            </div>
+
+            <div className="custom-control custom-radio  form-group">
+              <input
+                type="radio"
+                className="custom-control-input"
+                id="customRadio2"
+                value="customRadio2"
+                checked={expirationType === ExpirationType.NUMBER_OF_DAYS}
+                onChange={() => { handleChangeExpirationType(ExpirationType.NUMBER_OF_DAYS) }}
+                name="expirationType"
+              />
+              <label className="custom-control-label" htmlFor="customRadio2">
+                <div className="row align-items-center m-0">
+                  <input
+                    type="number"
+                    min="1"
+                    className="col-4"
+                    name="expirationType"
+                    value={numberOfDays}
+                    onFocus={() => { handleChangeExpirationType(ExpirationType.NUMBER_OF_DAYS) }}
+                    onChange={e => handleChangeNumberOfDays(Number(e.target.value))}
+                  />
+                  <span className="col-auto">{t('share_links.Days')}</span>
+                </div>
+              </label>
+            </div>
+
+            <div className="custom-control custom-radio form-group text-nowrap mb-0">
+              <input
+                type="radio"
+                className="custom-control-input"
+                id="customRadio3"
+                name="expirationType"
+                value="customRadio3"
+                checked={expirationType === ExpirationType.CUSTOM}
+                onChange={() => { handleChangeExpirationType(ExpirationType.CUSTOM) }}
+              />
+              <label className="custom-control-label" htmlFor="customRadio3">
+                {t('share_links.Custom')}
+              </label>
+              <div className="d-inline-flex flex-wrap">
+                <input
+                  type="date"
+                  className="ml-3 mb-2"
+                  name="customExpirationDate"
+                  value={format(customExpirationDate, 'yyyy-MM-dd')}
+                  onFocus={() => { handleChangeExpirationType(ExpirationType.CUSTOM) }}
+                  onChange={e => handleChangeCustomExpirationDate(e.target.value)}
+                />
+                <input
+                  type="time"
+                  className="ml-3 mb-2"
+                  name="customExpiration"
+                  value={format(customExpirationTime, 'HH:mm')}
+                  onFocus={() => { handleChangeExpirationType(ExpirationType.CUSTOM) }}
+                  onChange={e => handleChangeCustomExpirationTime(e.target.value)}
+                />
+              </div>
+            </div>
+          </div>
+        </div>
+
+        {/* DescriptionForm */}
+        <div className="form-group row">
+          <label htmlFor="inputDesc" className="col-md-5 col-form-label">{t('share_links.description')}</label>
+          <div className="col-md-4">
+            <input
+              type="text"
+              className="form-control"
+              id="inputDesc"
+              placeholder={t('share_links.enter_desc')}
+              value={description}
+              onChange={e => handleChangeDescription(e.target.value)}
+            />
+          </div>
+        </div>
+        <button type="button" className="btn btn-primary d-block mx-auto px-5" onClick={handleIssueShareLink}>
+          {t('share_links.Issue')}
+        </button>
+      </div>
+    </div>
+  );
+};

+ 31 - 14
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -1,5 +1,5 @@
 import React, {
-  useCallback, useState, FC, useEffect,
+  useCallback, useState, FC, useEffect, ReactNode,
 } from 'react';
 
 import nodePath from 'path';
@@ -94,6 +94,15 @@ const isDroppable = (fromPage?: Partial<IPageHasId>, newParentPage?: Partial<IPa
   return pagePathUtils.canMoveByPath(fromPage.path, newPathAfterMoved) && !pagePathUtils.isUsersTopPage(newParentPage.path);
 };
 
+// Component wrapper to make a child element not draggable
+// https://github.com/react-dnd/react-dnd/issues/335
+type NotDraggableProps = {
+  children: ReactNode,
+};
+const NotDraggableForClosableTextInput = (props: NotDraggableProps): JSX.Element => {
+  return <div draggable onDragStart={e => e.preventDefault()}>{props.children}</div>;
+};
+
 
 const Item: FC<ItemProps> = (props: ItemProps) => {
   const { t } = useTranslation();
@@ -440,13 +449,17 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
         </div>
         { isRenameInputShown
           ? (
-            <ClosableTextInput
-              value={nodePath.basename(page.path ?? '')}
-              placeholder={t('Input page name')}
-              onClickOutside={() => { setRenameInputShown(false) }}
-              onPressEnter={onPressEnterForRenameHandler}
-              inputValidator={inputValidator}
-            />
+            <div className="flex-fill">
+              <NotDraggableForClosableTextInput>
+                <ClosableTextInput
+                  value={nodePath.basename(page.path ?? '')}
+                  placeholder={t('Input page name')}
+                  onClickOutside={() => { setRenameInputShown(false) }}
+                  onPressEnter={onPressEnterForRenameHandler}
+                  inputValidator={inputValidator}
+                />
+              </NotDraggableForClosableTextInput>
+            </div>
           )
           : (
             <>
@@ -502,12 +515,16 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       </li>
 
       {isEnableActions && isNewPageInputShown && (
-        <ClosableTextInput
-          placeholder={t('Input page name')}
-          onClickOutside={() => { setNewPageInputShown(false) }}
-          onPressEnter={onPressEnterForCreateHandler}
-          inputValidator={inputValidator}
-        />
+        <div className="flex-fill">
+          <NotDraggableForClosableTextInput>
+            <ClosableTextInput
+              placeholder={t('Input page name')}
+              onClickOutside={() => { setNewPageInputShown(false) }}
+              onPressEnter={onPressEnterForCreateHandler}
+              inputValidator={inputValidator}
+            />
+          </NotDraggableForClosableTextInput>
+        </div>
       )}
       {
         isOpen && hasChildren() && currentChildren.map((node, index) => (

+ 9 - 0
packages/app/src/components/Sidebar/RecentChanges.module.scss

@@ -38,4 +38,13 @@
   .icon-lock {
     font-size: 14px;
   }
+
+  // For truncate-text
+  .flex-grow-1 {
+    min-width: 0;
+  }
+
+  .truncate-text {
+    max-width: fit-content;
+  }
 }

+ 1 - 1
packages/app/src/components/Sidebar/RecentChanges.tsx

@@ -117,7 +117,7 @@ const SmallPageItem = memo(({ page }: PageItemProps): JSX.Element => {
         <UserPicture user={page.lastUpdateUser} size="md" noTooltip />
         <div className="flex-grow-1 ml-2">
           { !dPagePath.isRoot && <FormerLink /> }
-          <h5 className="my-0">
+          <h5 className="my-0 text-truncate">
             <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.isRoot ? undefined : dPagePath.former} />
             {locked}
           </h5>

+ 5 - 2
packages/app/src/interfaces/editor-methods.ts

@@ -1,15 +1,18 @@
 export interface IEditorMethods {
   forceToFocus: () => void,
   setValue: (newValue: string) => void,
-  setGfmMode: (bool: boolean) => void,
   setCaretLine: (line: number) => void,
   setScrollTopByLine: (line: number) => void,
+  insertText: (text: string) => void,
+  terminateUploadingState: () => void,
+}
+
+export interface IEditorInnerMethods {
   getStrFromBol(): void,
   getStrToEol: () => void,
   getStrFromBolToSelectedUpperPos: () => void,
   replaceBolToCurrentPos: (text: string) => void,
   replaceLine: (text: string) => void,
-  insertText: (text: string) => void,
   insertLinebreak: () => void,
   dispatchSave: () => void,
   dispatchPasteFiles: (event: Event) => void,

+ 1 - 1
packages/app/src/interfaces/ui.ts

@@ -12,7 +12,7 @@ export type SidebarContentsType = typeof SidebarContentsType[keyof typeof Sideba
 
 export type ICustomTabContent = {
   Content: () => JSX.Element,
-  i18n: string,
+  i18n?: string,
   Icon?: () => JSX.Element,
   index?: number,
   isLinkEnabled?: boolean | ((content: ICustomTabContent) => boolean),

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

@@ -34,7 +34,7 @@ import { IUserUISettings } from '~/interfaces/user-ui-settings';
 import { PageModel, PageDocument } from '~/server/models/page';
 import { PageRedirectModel } from '~/server/models/page-redirect';
 import { UserUISettingsModel } from '~/server/models/user-ui-settings';
-import { useSWRxCurrentPage, useSWRxIsGrantNormalized, useSWRxPageInfo } from '~/stores/page';
+import { useSWRxCurrentPage, useSWRxIsGrantNormalized } from '~/stores/page';
 import { useRedirectFrom } from '~/stores/page-redirect';
 import {
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth, useSelectedGrant,
@@ -198,7 +198,6 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   useIsNotFound(props.isNotFound);
   useIsNotCreatable(props.IsNotCreatable);
   useRedirectFrom(props.redirectFrom);
-  // useIsTrashPage(_isTrashPage(props.currentPagePath));
   // useShared();
   // useShareLinkId(props.shareLinkId);
   useIsSharedUser(false); // this page cann't be routed for '/share'
@@ -242,13 +241,13 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
 
   useCurrentPageId(pageId);
   useSWRxCurrentPage(undefined, pageWithMeta?.data); // store initial data
-  useSWRxPageInfo(pageId, undefined, pageWithMeta?.meta); // store initial data
-  useIsTrashPage(_isTrashPage(pagePath));
   useIsUserPage(isUserPage(pagePath));
   useIsNotCreatable(props.isForbidden || !isCreatablePage(pagePath)); // TODO: need to include props.isIdentical
   useCurrentPagePath(pagePath);
   useCurrentPathname(props.currentPathname);
   useEditingMarkdown(pageWithMeta?.data.revision?.body);
+  useIsTrashPage(_isTrashPage(pagePath));
+
   const { data: grantData } = useSWRxIsGrantNormalized(pageId);
   const { mutate: mutateSelectedGrant } = useSelectedGrant();
 

+ 2 - 4
packages/app/src/pages/installer.page.tsx

@@ -47,10 +47,8 @@ const InstallerPage: NextPage<Props> = (props: Props) => {
 
   return (
     <NoLoginLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')}>
-      <div className="col-md-12">
-        <div id="installer-form-container">
-          <InstallerForm />
-        </div>
+      <div id="installer-form-container">
+        <InstallerForm />
       </div>
     </NoLoginLayout>
   );

+ 87 - 0
packages/app/src/pages/invited.page.tsx

@@ -0,0 +1,87 @@
+import React from 'react';
+
+import { IUserHasId, IUser } from '@growi/core';
+import { NextPage, GetServerSideProps, GetServerSidePropsContext } from 'next';
+import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
+import dynamic from 'next/dynamic';
+
+import { InvitedFormProps } from '~/components/InvitedForm';
+import { NoLoginLayout } from '~/components/Layout/NoLoginLayout';
+import { CrowiRequest } from '~/interfaces/crowi-request';
+
+import { useCsrfToken, useCurrentPathname, useCurrentUser } from '../stores/context';
+
+import {
+  CommonProps, getServerSideCommonProps, useCustomTitle, getNextI18NextConfig,
+} from './utils/commons';
+
+const InvitedForm = dynamic<InvitedFormProps>(() => import('~/components/InvitedForm').then(mod => mod.InvitedForm), { ssr: false });
+
+type Props = CommonProps & {
+  currentUser: IUser,
+  invitedFormUsername: string,
+  invitedFormName: string,
+}
+
+const InvitedPage: NextPage<Props> = (props: Props) => {
+
+  useCsrfToken(props.csrfToken);
+  useCurrentPathname(props.currentPathname);
+  useCurrentUser(props.currentUser);
+
+  const classNames: string[] = ['invited-page'];
+
+  return (
+    <NoLoginLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')}>
+      <InvitedForm invitedFormUsername={props.invitedFormUsername} invitedFormName={props.invitedFormName} />
+    </NoLoginLayout>
+  );
+
+};
+
+async function injectServerConfigurations(context: GetServerSidePropsContext, props: Props): Promise<void> {
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const { body: invitedForm } = req;
+
+  if (props.invitedFormUsername != null) {
+    props.invitedFormUsername = invitedForm.username;
+  }
+  if (props.invitedFormName != null) {
+    props.invitedFormName = invitedForm.name;
+  }
+}
+
+/**
+ * for Server Side Translations
+ * @param context
+ * @param props
+ * @param namespacesRequired
+ */
+async function injectNextI18NextConfigurations(context: GetServerSidePropsContext, props: Props, namespacesRequired?: string[] | undefined): Promise<void> {
+  const nextI18NextConfig = await getNextI18NextConfig(serverSideTranslations, context, namespacesRequired);
+  props._nextI18Next = nextI18NextConfig._nextI18Next;
+}
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const req = context.req as CrowiRequest<IUserHasId & any>;
+  const { user } = req;
+  const result = await getServerSideCommonProps(context);
+
+  // check for presence
+  // see: https://github.com/vercel/next.js/issues/19271#issuecomment-730006862
+  if (!('props' in result)) {
+    throw new Error('invalid getSSP result');
+  }
+  const props: Props = result.props as Props;
+
+  if (user != null) {
+    props.currentUser = user.toObject();
+  }
+
+  await injectServerConfigurations(context, props);
+  await injectNextI18NextConfigurations(context, props, ['translation']);
+
+  return { props };
+};
+
+export default InvitedPage;

+ 4 - 8
packages/app/src/pages/login.page.tsx

@@ -19,6 +19,8 @@ import {
   CommonProps, getServerSideCommonProps, useCustomTitle,
 } from './utils/commons';
 
+const LoginForm = dynamic(() => import('~/components/LoginForm'), { ssr: false });
+
 type Props = CommonProps & {
 
   pageWithMetaStr: string,
@@ -37,16 +39,10 @@ const LoginPage: NextPage<Props> = (props: Props) => {
 
   const classNames: string[] = ['login-page'];
 
-  const LoginForm = dynamic(() => import('~/components/LoginForm'), {
-    ssr: false,
-  });
-
   return (
     <NoLoginLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')}>
-      <div className="col-md-12">
-        <LoginForm objOfIsExternalAuthEnableds={props.enabledStrategies} isLocalStrategySetup={true} isLdapStrategySetup={true}
-          isRegistrationEnabled={true} registrationWhiteList={props.registrationWhiteList} isPasswordResetEnabled={true} />
-      </div>
+      <LoginForm objOfIsExternalAuthEnableds={props.enabledStrategies} isLocalStrategySetup={true} isLdapStrategySetup={true}
+        isRegistrationEnabled={true} registrationWhiteList={props.registrationWhiteList} isPasswordResetEnabled={true} />
     </NoLoginLayout>
   );
 };

+ 16 - 7
packages/app/src/pages/trash.page.tsx

@@ -1,18 +1,19 @@
+import React from 'react';
+
 import {
   IUser, IUserHasId,
 } from '@growi/core';
-
-import dynamic from 'next/dynamic';
 import { NextPage, GetServerSideProps, GetServerSidePropsContext } from 'next';
+import dynamic from 'next/dynamic';
 
-import GrowiContextualSubNavigation from '~/components/Navbar/GrowiContextualSubNavigation';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { IUserUISettings } from '~/interfaces/user-ui-settings';
 import UserUISettings from '~/server/models/user-ui-settings';
 
 import { BasicLayout } from '../components/Layout/BasicLayout';
+import GrowiContextualSubNavigation from '../components/Navbar/GrowiContextualSubNavigation';
 import {
-  useCurrentUser, useIsTrashPage, useCurrentPagePath, useCurrentPathname,
+  useCurrentUser, useCurrentPageId, useCurrentPagePath, useCurrentPathname,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
   useIsSearchScopeChildrenAsDefault,
 } from '../stores/context';
@@ -21,6 +22,10 @@ import {
   CommonProps, getServerSideCommonProps, useCustomTitle,
 } from './utils/commons';
 
+const TrashPageList = dynamic(() => import('~/components/TrashPageList').then(mod => mod.TrashPageList), { ssr: false });
+const EmptyTrashModal = dynamic(() => import('~/components/EmptyTrashModal'), { ssr: false });
+const PutbackPageModal = dynamic(() => import('~/components/PutbackPageModal'), { ssr: false });
+
 type Props = CommonProps & {
   currentUser: IUser,
   isSearchServiceConfigured: boolean,
@@ -30,15 +35,13 @@ type Props = CommonProps & {
 };
 
 const TrashPage: NextPage<CommonProps> = (props: Props) => {
-  const TrashPageList = dynamic(() => import('~/components/TrashPageList').then(mod => mod.TrashPageList), { ssr: false });
-
   useCurrentUser(props.currentUser ?? null);
 
   useIsSearchServiceConfigured(props.isSearchServiceConfigured);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
 
-  useIsTrashPage(true);
+  useCurrentPageId(null);
   useCurrentPathname('/trash');
   useCurrentPagePath('/trash');
 
@@ -48,10 +51,16 @@ const TrashPage: NextPage<CommonProps> = (props: Props) => {
         <header className="py-0 position-relative">
           <GrowiContextualSubNavigation isLinkSharingDisabled={false} />
         </header>
+
         <div className="grw-container-convertible mb-5 pb-5">
           <TrashPageList />
         </div>
+
+        <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
       </BasicLayout>
+
+      <EmptyTrashModal />
+      <PutbackPageModal />
     </>
   );
 };

+ 13 - 10
packages/app/src/server/crowi/dev.js

@@ -1,5 +1,7 @@
 import path from 'path';
 
+import express from 'express';
+
 import { i18n } from '^/config/next-i18next.config';
 
 import loggerFactory from '~/utils/logger';
@@ -27,7 +29,6 @@ class CrowiDev {
     this.requireForAutoReloadServer();
 
     this.initPromiseRejectionWarningHandler();
-    this.initSwig();
   }
 
   initPromiseRejectionWarningHandler() {
@@ -35,10 +36,6 @@ class CrowiDev {
     process.on('unhandledRejection', console.dir); // eslint-disable-line no-console
   }
 
-  initSwig() {
-    swig.setDefaults({ cache: false });
-  }
-
   /**
    * require files for node-dev auto reloading
    */
@@ -58,6 +55,8 @@ class CrowiDev {
     const port = this.crowi.port;
     let server = app;
 
+    this.setupExpressBeforeListening(app);
+
     // for log
     let serverUrl = `http://localhost:${port}}`;
 
@@ -90,12 +89,11 @@ class CrowiDev {
     return server;
   }
 
-  /**
-   *
-   * @param {any} app express
-   */
+  setupExpressBeforeListening(app) {
+    this.setupNextBundleAnalyzer(app);
+  }
+
   setupExpressAfterListening(app) {
-    // this.setupHeaderDebugger(app);
     // this.setupBrowserSync(app);
     this.setupWebpackHmr(app);
     this.setupNextjsStackFrame(app);
@@ -128,6 +126,11 @@ class CrowiDev {
   //   app.use(require('connect-browser-sync')(bs));
   // }
 
+  setupNextBundleAnalyzer(app) {
+    const next = nextFactory(this.crowi);
+    app.use('/analyze', express.static(path.resolve(__dirname, '../../../.next/analyze')));
+  }
+
   setupWebpackHmr(app) {
     const next = nextFactory(this.crowi);
     app.all('/_next/webpack-hmr', next.delegateToNext);

+ 1 - 1
packages/app/src/server/middlewares/login-required.js

@@ -27,7 +27,7 @@ module.exports = (crowi, isGuestAllowed = false, fallback = null) => {
         return res.redirect('/login/error/suspended');
       }
       if (req.user.status === User.STATUS_INVITED) {
-        return res.redirect('/login/invited');
+        return res.redirect('/invited');
       }
     }
 

+ 5 - 0
packages/app/src/server/routes/apiv3/page.js

@@ -599,6 +599,11 @@ module.exports = (crowi) => {
 
       const Revision = crowi.model('Revision');
       revision = await Revision.findById(revisionIdForFind);
+
+      // Error if pageId and revison's pageIds do not match
+      if (page._id.toString() !== revision.pageId.toString()) {
+        return res.apiv3Err(new ErrorV3("Haven't the right to see the page."), 403);
+      }
     }
     catch (err) {
       logger.error('Failed to get page data', err);

+ 3 - 3
packages/app/src/server/routes/index.js

@@ -80,15 +80,15 @@ module.exports = function(crowi, app) {
 
   app.get('/login/error/:reason'      , applicationInstalled, login.error);
   app.get('/login'                    , applicationInstalled, login.preLogin, next.delegateToNext);
-  app.get('/login/invited'            , applicationInstalled, login.invited);
-  app.post('/login/activateInvited'   , applicationInstalled, loginFormValidator.inviteRules(), loginFormValidator.inviteValidation, csrfProtection, login.invited);
+  app.get('/invited'                  , applicationInstalled, next.delegateToNext);
+  app.post('/invited/activateInvited' , applicationInstalled, loginFormValidator.inviteRules(), loginFormValidator.inviteValidation, csrfProtection, login.invited);
   app.post('/login'                   , applicationInstalled, loginFormValidator.loginRules(), loginFormValidator.loginValidation, csrfProtection,  addActivity, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.loginFailure);
 
   app.post('/register'                , applicationInstalled, registerFormValidator.registerRules(), registerFormValidator.registerValidation, csrfProtection, addActivity, login.register);
   app.get('/register'                 , applicationInstalled, login.preLogin, login.register);
 
   app.get('/admin/*'                    , applicationInstalled, loginRequiredStrictly , adminRequired , next.delegateToNext);
-  // app.get('/admin'                    , applicationInstalled, loginRequiredStrictly , adminRequired , admin.index);
+  app.get('/admin'                    , applicationInstalled, loginRequiredStrictly , adminRequired , next.delegateToNext);
   // app.get('/admin/app'                , applicationInstalled, loginRequiredStrictly , adminRequired , admin.app.index);
 
   // installer

+ 21 - 14
packages/app/src/server/service/global-notification/global-notification-mail.js

@@ -20,17 +20,17 @@ class GlobalNotificationMailService {
    * @memberof GlobalNotificationMailService
    *
    * @param {string} event event name triggered
-   * @param {string} path path triggered the event
+   * @param {import('~/server/models/page').PageDocument} page page triggered the event
    * @param {User} triggeredBy user who triggered the event
    * @param {{ comment: Comment, oldPath: string }} _ event specific vars
    */
-  async fire(event, path, triggeredBy, vars) {
+  async fire(event, page, triggeredBy, vars) {
     const { mailService } = this.crowi;
 
     const GlobalNotification = this.crowi.model('GlobalNotificationSetting');
-    const notifications = await GlobalNotification.findSettingByPathAndEvent(event, path, this.type);
+    const notifications = await GlobalNotification.findSettingByPathAndEvent(event, page.path, this.type);
 
-    const option = this.generateOption(event, path, triggeredBy, vars);
+    const option = this.generateOption(event, page, triggeredBy, vars);
 
     await Promise.all(notifications.map((notification) => {
       return mailService.send({ ...option, to: notification.toEmail });
@@ -43,38 +43,45 @@ class GlobalNotificationMailService {
    * @memberof GlobalNotificationMailService
    *
    * @param {string} event event name triggered
-   * @param {string} path path triggered the event
+   * @param {import('~/server/models/page').PageDocument} page path triggered the event
    * @param {User} triggeredBy user triggered the event
    * @param {{ comment: Comment, oldPath: string }} _ event specific vars
    *
    * @return  {{ subject: string, template: string, vars: object }}
    */
-  generateOption(event, path, triggeredBy, { comment, oldPath }) {
+  generateOption(event, page, triggeredBy, { comment, oldPath }) {
     const defaultLang = this.crowi.configManager.getConfig('crowi', 'app:globalLang');
     // validate for all events
-    if (event == null || path == null || triggeredBy == null) {
+    if (event == null || page == null || triggeredBy == null) {
       throw new Error(`invalid vars supplied to GlobalNotificationMailService.generateOption for event ${event}`);
     }
 
     const template = nodePath.join(this.crowi.localeDir, `${defaultLang}/notifications/${event}.txt`);
+
+    const path = page.path;
+    const appTitle = this.crowi.appService.getAppTitle();
+    const siteUrl = this.crowi.appService.getSiteUrl();
+    const pageUrl = new URL(page._id, siteUrl);
+
     let subject;
     let vars = {
-      appTitle: this.crowi.appService.getAppTitle(),
+      appTitle,
+      siteUrl,
       path,
       username: triggeredBy.username,
     };
 
     switch (event) {
       case this.event.PAGE_CREATE:
-        subject = `#${event} - ${triggeredBy.username} created ${path}`;
+        subject = `#${event} - ${triggeredBy.username} created ${path} at URL: ${pageUrl}`;
         break;
 
       case this.event.PAGE_EDIT:
-        subject = `#${event} - ${triggeredBy.username} edited ${path}`;
+        subject = `#${event} - ${triggeredBy.username} edited ${path} at URL: ${pageUrl}`;
         break;
 
       case this.event.PAGE_DELETE:
-        subject = `#${event} - ${triggeredBy.username} deleted ${path}`;
+        subject = `#${event} - ${triggeredBy.username} deleted ${path} at URL: ${pageUrl}`;
         break;
 
       case this.event.PAGE_MOVE:
@@ -83,7 +90,7 @@ class GlobalNotificationMailService {
           throw new Error(`invalid vars supplied to GlobalNotificationMailService.generateOption for event ${event}`);
         }
 
-        subject = `#${event} - ${triggeredBy.username} moved ${oldPath} to ${path}`;
+        subject = `#${event} - ${triggeredBy.username} moved ${oldPath} to ${path} at URL: ${pageUrl}`;
         vars = {
           ...vars,
           oldPath,
@@ -92,7 +99,7 @@ class GlobalNotificationMailService {
         break;
 
       case this.event.PAGE_LIKE:
-        subject = `#${event} - ${triggeredBy.username} liked ${path}`;
+        subject = `#${event} - ${triggeredBy.username} liked ${path} at URL: ${pageUrl}`;
         break;
 
       case this.event.COMMENT:
@@ -101,7 +108,7 @@ class GlobalNotificationMailService {
           throw new Error(`invalid vars supplied to GlobalNotificationMailService.generateOption for event ${event}`);
         }
 
-        subject = `#${event} - ${triggeredBy.username} commented on ${path}`;
+        subject = `#${event} - ${triggeredBy.username} commented on ${path} at URL: ${pageUrl}`;
         vars = {
           ...vars,
           comment: comment.comment,

+ 1 - 1
packages/app/src/server/service/global-notification/index.js

@@ -45,7 +45,7 @@ class GlobalNotificationService {
     }
 
     await Promise.all([
-      this.gloabalNotificationMail.fire(event, page.path, triggeredBy, vars),
+      this.gloabalNotificationMail.fire(event, page, triggeredBy, vars),
       this.gloabalNotificationSlack.fire(event, page.id, page.path, triggeredBy, vars),
     ]);
   }

+ 4 - 1
packages/app/src/styles/style-next.scss

@@ -17,9 +17,12 @@
 @import '~katex/dist/katex.min';
 
 // icons
+
+// DO NOT CHANGE THER OERDER OF font-awesome AND simple-line-icons.
+// font-familiy used in simple-line-icons has to be prioritized than the one used in font-awesome.
+@import '~font-awesome';
 @import '~simple-line-icons';
 @import '~material-icons/iconfont/filled';
-@import '~font-awesome';
 @import '~@icon/themify-icons/themify-icons';
 
 // atoms

+ 1 - 1
packages/app/test/cypress/integration/10-install/install.spec.ts

@@ -53,7 +53,7 @@ context('Installing', () => {
     cy.getByTestid('btnSubmit').click();
 
     cy.screenshot(`${ssPrefix}-installed`, {
-      blackout: ['#grw-sidebar-contents-wrapper'],
+      blackout: ['#grw-sidebar-contents-wrapper','[data-line="2"]:eq(0) > a > img', '[data-hide-in-vrt=true]'],
     });
   });
 

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

@@ -218,7 +218,7 @@ context('Tag Oprations', () =>{
       cy.get('.modal-footer > button.btn').click();
     });
     cy.visit(`/${newPageName}`);
-    cy.get('#wiki').should('not.be.empty');
+    cy.getByTestid('wiki').should('exist');
     cy.screenshot(`${ssPrefix}4-duplicated-page`, {capture: 'viewport'});
   });
 

+ 1 - 1
packages/app/test/cypress/integration/50-sidebar/access-to-side-bar.spec.ts

@@ -102,7 +102,7 @@ context('Access to sidebar', () => {
     });
 
     cy.get('.grw-pagetree-item-children').eq(0).within(() => {
-      cy.get('.flex-fill > input').type('_newname');
+      cy.getByTestid('closable-text-input').type('_newname');
     });
 
     cy.getByTestid('grw-contextual-navigation-sub').screenshot(`${ssPrefix}page-tree-6-rename-page`);

+ 3 - 3
packages/app/test/integration/middlewares/login-required.test.js

@@ -47,7 +47,7 @@ describe('loginRequired', () => {
         userStatus  | expectedPath
         ${1}        | ${'/login/error/registered'}
         ${3}        | ${'/login/error/suspended'}
-        ${5}        | ${'/login/invited'}
+        ${5}        | ${'/invited'}
       `('redirect to \'$expectedPath\' when user.status is \'$userStatus\'', ({ userStatus, expectedPath }) => {
 
         req.user = {
@@ -122,7 +122,7 @@ describe('loginRequired', () => {
         userStatus  | expectedPath
         ${1}        | ${'/login/error/registered'}
         ${3}        | ${'/login/error/suspended'}
-        ${5}        | ${'/login/invited'}
+        ${5}        | ${'/invited'}
       `('redirect to \'$expectedPath\' when user.status is \'$userStatus\'', ({ userStatus, expectedPath }) => {
 
         req.user = {
@@ -248,7 +248,7 @@ describe('loginRequired', () => {
       userStatus  | expectedPath
       ${1}        | ${'/login/error/registered'}
       ${3}        | ${'/login/error/suspended'}
-      ${5}        | ${'/login/invited'}
+      ${5}        | ${'/invited'}
     `('redirect to \'$expectedPath\' when user.status is \'$userStatus\'', ({ userStatus, expectedPath }) => {
       req.user = {
         _id: 'user id',

+ 1 - 1
packages/codemirror-textlint/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/codemirror-textlint",
-  "version": "5.1.4-RC.0",
+  "version": "5.1.5-RC.0",
   "license": "MIT",
   "main": "dist/index.js",
   "scripts": {

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/core",
-  "version": "5.1.4-RC.0",
+  "version": "5.1.5-RC.0",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-attachment-refs/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-attachment-refs",
-  "version": "5.1.4-RC.0",
+  "version": "5.1.5-RC.0",
   "description": "GROWI Plugin to add ref/refimg/refs/refsimg tags",
   "license": "MIT",
   "keywords": [

+ 3 - 3
packages/plugin-lsx/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-lsx",
-  "version": "5.1.4-RC.0",
+  "version": "5.1.5-RC.0",
   "description": "GROWI plugin to list pages",
   "license": "MIT",
   "keywords": [
@@ -28,8 +28,8 @@
     "test": ""
   },
   "dependencies": {
-    "@growi/core": "^5.1.4-RC.0",
-    "@growi/remark-growi-plugin": "^5.1.4-RC.0"
+    "@growi/core": "^5.1.5-RC.0",
+    "@growi/remark-growi-plugin": "^5.1.5-RC.0"
   },
   "devDependencies": {
     "eslint-plugin-regex": "^1.8.0",

+ 0 - 109
packages/plugin-lsx/src/components/LsxPageList/LsxPage.jsx

@@ -1,109 +0,0 @@
-import React from 'react';
-
-import { pathUtils } from '@growi/core';
-import { PageListMeta } from '@growi/ui';
-import PropTypes from 'prop-types';
-
-import { PageNode } from '../PageNode';
-import { LsxContext } from '../lsx-context';
-
-import { PagePathWrapper } from './PagePathWrapper';
-
-export class LsxPage extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isExists: false,
-      isLinkable: false,
-      hasChildren: false,
-    };
-  }
-
-  UNSAFE_componentWillMount() {
-    const pageNode = this.props.pageNode;
-
-    if (pageNode.page !== undefined) {
-      this.setState({ isExists: true });
-    }
-    if (pageNode.children.length > 0) {
-      this.setState({ hasChildren: true });
-    }
-
-    // process depth option
-    const optDepth = this.props.lsxContext.getOptDepth();
-    if (optDepth == null) {
-      this.setState({ isLinkable: true });
-    }
-    else {
-      const depth = this.props.depth;
-
-      // debug
-      // console.log(pageNode.pagePath, {depth, decGens, 'optDepth.start': optDepth.start, 'optDepth.end': optDepth.end});
-
-      const isLinkable = optDepth.start <= depth;
-      this.setState({ isLinkable });
-    }
-  }
-
-  getChildPageElement() {
-    const pageNode = this.props.pageNode;
-
-    let element = '';
-
-    // create child pages elements
-    if (this.state.hasChildren) {
-      const pages = pageNode.children.map((pageNode) => {
-        return (
-          <LsxPage
-            key={pageNode.pagePath}
-            depth={this.props.depth + 1}
-            pageNode={pageNode}
-            lsxContext={this.props.lsxContext}
-            basisViewersCount={this.props.basisViewersCount}
-          />
-        );
-      });
-
-      element = <ul className="page-list-ul">{pages}</ul>;
-    }
-
-    return element;
-  }
-
-  getIconElement() {
-    return (this.state.isExists)
-      ? <i className="ti ti-agenda" aria-hidden="true"></i>
-      : <i className="ti ti-file lsx-page-not-exist" aria-hidden="true"></i>;
-  }
-
-  render() {
-    const { pageNode, basisViewersCount } = this.props;
-
-    // create PagePath element
-    let pagePathNode = <PagePathWrapper pagePath={pageNode.pagePath} isExists={this.state.isExists} />;
-    if (this.state.isLinkable) {
-      pagePathNode = <a className="page-list-link" href={encodeURI(pathUtils.removeTrailingSlash(pageNode.pagePath))}>{pagePathNode}</a>;
-    }
-
-    // create PageListMeta element
-    const pageListMeta = (this.state.isExists) ? <PageListMeta page={pageNode.page} basisViewersCount={basisViewersCount} /> : '';
-
-    return (
-      <li className="page-list-li">
-        <small>{this.getIconElement()}</small> {pagePathNode}
-        <span className="ml-2">{pageListMeta}</span>
-        {this.getChildPageElement()}
-      </li>
-    );
-  }
-
-}
-
-LsxPage.propTypes = {
-  pageNode: PropTypes.instanceOf(PageNode).isRequired,
-  lsxContext: PropTypes.instanceOf(LsxContext).isRequired,
-  depth: PropTypes.number,
-  basisViewersCount: PropTypes.number,
-};

+ 95 - 0
packages/plugin-lsx/src/components/LsxPageList/LsxPage.tsx

@@ -0,0 +1,95 @@
+import React, { useMemo } from 'react';
+
+import { pathUtils } from '@growi/core';
+import { PagePathLabel, PageListMeta } from '@growi/ui';
+
+import { PageNode } from '../PageNode';
+import { LsxContext } from '../lsx-context';
+
+
+type Props = {
+  pageNode: PageNode,
+  lsxContext: LsxContext,
+  depth: number,
+  basisViewersCount?: number,
+};
+
+export const LsxPage = React.memo((props: Props): JSX.Element => {
+  const {
+    pageNode, lsxContext, depth, basisViewersCount,
+  } = props;
+
+  const isExists = pageNode.page !== undefined;
+  const isLinkable = (() => {
+    // process depth option
+    const optDepth = lsxContext.getOptDepth();
+    if (optDepth == null) {
+      return true;
+    }
+
+    // debug
+    // console.log(pageNode.pagePath, {depth, decGens, 'optDepth.start': optDepth.start, 'optDepth.end': optDepth.end});
+
+    return optDepth.start <= depth;
+  })();
+  const hasChildren = pageNode.children.length > 0;
+
+  const childrenElements: JSX.Element = useMemo(() => {
+    let element = <></>;
+
+    // create child pages elements
+    if (hasChildren) {
+      const pages = pageNode.children.map((pageNode) => {
+        return (
+          <LsxPage
+            key={pageNode.pagePath}
+            depth={depth + 1}
+            pageNode={pageNode}
+            lsxContext={lsxContext}
+            basisViewersCount={basisViewersCount}
+          />
+        );
+      });
+
+      element = <ul className="page-list-ul">{pages}</ul>;
+    }
+
+    return element;
+  }, [basisViewersCount, depth, hasChildren, lsxContext, pageNode.children]);
+
+  const iconElement: JSX.Element = useMemo(() => {
+    return (isExists)
+      ? <i className="ti ti-agenda" aria-hidden="true"></i>
+      : <i className="ti ti-file lsx-page-not-exist" aria-hidden="true"></i>;
+  }, [isExists]);
+
+  const pagePathElement: JSX.Element = useMemo(() => {
+    const classNames: string[] = [];
+    if (!isExists) {
+      classNames.push('lsx-page-not-exist');
+    }
+
+    // create PagePath element
+    let pagePathNode = <PagePathLabel path={pageNode.pagePath} isLatterOnly additionalClassNames={classNames} />;
+    if (isLinkable) {
+      pagePathNode = <a className="page-list-link" href={encodeURI(pathUtils.removeTrailingSlash(pageNode.pagePath))}>{pagePathNode}</a>;
+    }
+    return pagePathNode;
+  }, [isExists, isLinkable, pageNode.pagePath]);
+
+  const pageListMetaElement: JSX.Element = useMemo(() => {
+    if (!isExists) {
+      return <></>;
+    }
+    return <PageListMeta page={pageNode.page} basisViewersCount={basisViewersCount} />;
+  }, [basisViewersCount, isExists, pageNode.page]);
+
+  return (
+    <li className="page-list-li">
+      <small>{iconElement}</small> {pagePathElement}
+      <span className="ml-2">{pageListMetaElement}</span>
+      {childrenElements}
+    </li>
+  );
+
+});

+ 0 - 31
packages/plugin-lsx/src/components/LsxPageList/PagePathWrapper.jsx

@@ -1,31 +0,0 @@
-import React from 'react';
-
-import { PagePathLabel } from '@growi/ui';
-import PropTypes from 'prop-types';
-
-
-export class PagePathWrapper extends React.Component {
-
-  render() {
-
-    const classNames = [];
-    if (!this.props.isExists) {
-      classNames.push('lsx-page-not-exist');
-    }
-
-    return (
-      <PagePathLabel path={this.props.pagePath} isLatterOnly additionalClassNames={classNames} />
-    );
-  }
-
-}
-
-PagePathWrapper.propTypes = {
-  pagePath: PropTypes.string.isRequired,
-  isExists: PropTypes.bool.isRequired,
-  excludePathString: PropTypes.string,
-};
-
-PagePathWrapper.defaultProps = {
-  excludePathString: '',
-};

+ 1 - 1
packages/remark-growi-plugin/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/remark-growi-plugin",
-  "version": "5.1.4-RC.0",
+  "version": "5.1.5-RC.0",
   "description": "remark plugin to support GROWI plugin (forked from remark-directive@2.0.1)",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slack",
-  "version": "5.1.4-RC.0",
+  "version": "5.1.5-RC.0",
   "license": "MIT",
   "main": "dist/index.js",
   "typings": "dist/index.d.ts",

+ 2 - 2
packages/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "5.1.4-slackbot-proxy.0",
+  "version": "5.1.5-slackbot-proxy.0",
   "license": "MIT",
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
@@ -26,7 +26,7 @@
   },
   "dependencies": {
     "@godaddy/terminus": "^4.9.0",
-    "@growi/slack": "^5.1.4-RC.0",
+    "@growi/slack": "^5.1.5-RC.0",
     "@slack/oauth": "^2.0.1",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "^6.43.0",

+ 2 - 2
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/ui",
-  "version": "5.1.4-RC.0",
+  "version": "5.1.5-RC.0",
   "description": "GROWI UI Libraries",
   "license": "MIT",
   "keywords": [
@@ -21,7 +21,7 @@
     "test": "jest --verbose"
   },
   "dependencies": {
-    "@growi/core": "^5.1.4-RC.0"
+    "@growi/core": "^5.1.5-RC.0"
   },
   "devDependencies": {
     "eslint-plugin-regex": "^1.8.0",