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

Merge branch 'support/apply-nextjs-2' into feat/integrate-next-show-sidebar

yohei0125 3 лет назад
Родитель
Сommit
0f5cd269e7
50 измененных файлов с 557 добавлено и 326 удалено
  1. 21 1
      CHANGELOG.md
  2. 1 1
      lerna.json
  3. 1 1
      package.json
  4. 2 2
      packages/app/docker/README.md
  5. 8 8
      packages/app/package.json
  6. 2 1
      packages/app/public/static/locales/en_US/translation.json
  7. 2 1
      packages/app/public/static/locales/ja_JP/translation.json
  8. 2 1
      packages/app/public/static/locales/zh_CN/translation.json
  9. 1 3
      packages/app/src/client/services/ContextExtractor.tsx
  10. 0 24
      packages/app/src/client/services/EditorContainer.js
  11. 2 1
      packages/app/src/components/ContentLinkButtons.tsx
  12. 6 28
      packages/app/src/components/NotFoundPage.tsx
  13. 9 6
      packages/app/src/components/Page.jsx
  14. 25 19
      packages/app/src/components/Page/DisplaySwitcher.tsx
  15. 18 4
      packages/app/src/components/PageEditor.tsx
  16. 4 5
      packages/app/src/components/PageEditor/EditorNavbarBottom.tsx
  17. 4 20
      packages/app/src/components/PageEditor/OptionsSelector.tsx
  18. 10 6
      packages/app/src/components/PageEditorByHackmd.jsx
  19. 4 0
      packages/app/src/components/PrivateLegacyPages.tsx
  20. 16 20
      packages/app/src/components/SavePageControls.jsx
  21. 22 22
      packages/app/src/components/StickyStretchableScroller.tsx
  22. 2 0
      packages/app/src/components/TagCloudBox.tsx
  23. 2 0
      packages/app/src/components/UncontrolledCodeMirror.tsx
  24. 0 5
      packages/app/src/interfaces/page-listing-results.ts
  25. 59 0
      packages/app/src/pages/UnsavedAlertDialog.tsx
  26. 84 35
      packages/app/src/pages/[[...path]].page.tsx
  27. 5 0
      packages/app/src/server/interfaces/search.ts
  28. 12 12
      packages/app/src/server/middlewares/login-required.js
  29. 1 1
      packages/app/src/server/models/interfaces/page-operation.ts
  30. 1 1
      packages/app/src/server/models/page-operation.ts
  31. 1 2
      packages/app/src/server/routes/login.js
  32. 1 4
      packages/app/src/server/routes/page.js
  33. 4 0
      packages/app/src/server/service/interfaces/search.ts
  34. 15 6
      packages/app/src/server/service/page.ts
  35. 9 9
      packages/app/src/server/service/search-delegator/elasticsearch.ts
  36. 0 1
      packages/app/src/server/views/layout-growi/not_found.html
  37. 9 12
      packages/app/src/stores/context.tsx
  38. 4 0
      packages/app/src/stores/editor.tsx
  39. 10 10
      packages/app/src/stores/ui.tsx
  40. 142 45
      packages/app/test/integration/middlewares/login-required.test.js
  41. 1 1
      packages/codemirror-textlint/package.json
  42. 1 1
      packages/core/package.json
  43. 11 0
      packages/core/src/utils/page-path-utils.ts
  44. 16 0
      packages/core/src/utils/path-utils.js
  45. 1 1
      packages/plugin-attachment-refs/package.json
  46. 1 1
      packages/plugin-lsx/package.json
  47. 1 1
      packages/plugin-pukiwiki-like-linker/package.json
  48. 1 1
      packages/slack/package.json
  49. 2 2
      packages/slackbot-proxy/package.json
  50. 1 1
      packages/ui/package.json

+ 21 - 1
CHANGELOG.md

@@ -1,9 +1,29 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v5.0.10...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v5.0.11...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v5.0.11](https://github.com/weseek/growi/compare/v5.0.10...v5.0.11) - 2022-07-05
+
+### 💎 Features
+
+- feat: Integrate recount descendant count after paths fix (#6170) @Yohei-Shiina
+
+### 🚀 Improvement
+
+- imprv: Redirect when the anchor is #password (#6144) @Kami-jo
+
+### 🐛 Bug Fixes
+
+- fix: User registration page is not redirected after tmp login (#6197) @kaoritokashiki
+- fix: Empty trash doesn't work (#6168) @yukendev
+
+### 🧰 Maintenance
+
+- support: Ease rate limit temporary (#6191) @yuki-takei
+- support: Omit page history container and page revision comparer container (#6185) @yukendev
+
 ## [v5.0.10](https://github.com/weseek/growi/compare/v5.0.9...v5.0.10) - 2022-06-27
 
 ### 💎 Features

+ 1 - 1
lerna.json

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

+ 1 - 1
package.json

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

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

@@ -10,8 +10,8 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`5.0.10`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.10/docker/Dockerfile)
-* [`5.0.10-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.10/docker/Dockerfile)
+* [`5.0.11`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.11/docker/Dockerfile)
+* [`5.0.11-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.11/docker/Dockerfile)
 * [`4.5.22`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.22/docker/Dockerfile)
 * [`4.5.22-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.22/docker/Dockerfile)
 * [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)

+ 8 - 8
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "5.0.11-RC.0",
+  "version": "5.0.12-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -29,7 +29,7 @@
     "dev:migrate:down": "yarn dev:migrate-mongo down",
     "cy:run": "cypress run --browser chrome",
     "//// for CI": "",
-    "dev:ci": "yarn dev:client:nowatch && yarn dev:server --ci",
+    "dev:ci": "yarn dev --ci",
     "predev:ci": "run-p resources:*",
     "lint:typecheck": "npx -y tsc",
     "lint:eslint": "eslint --quiet \"**/*.{js,jsx,ts,tsx}\"",
@@ -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.0.11-RC.0",
-    "@growi/plugin-attachment-refs": "^5.0.11-RC.0",
-    "@growi/plugin-lsx": "^5.0.11-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^5.0.11-RC.0",
-    "@growi/slack": "^5.0.11-RC.0",
+    "@growi/codemirror-textlint": "^5.0.12-RC.0",
+    "@growi/plugin-attachment-refs": "^5.0.12-RC.0",
+    "@growi/plugin-lsx": "^5.0.12-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^5.0.12-RC.0",
+    "@growi/slack": "^5.0.12-RC.0",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
@@ -170,7 +170,7 @@
     "handsontable": "v7.0.0 or above is no loger MIT lisence."
   },
   "devDependencies": {
-    "@growi/ui": "^5.0.11-RC.0",
+    "@growi/ui": "^5.0.12-RC.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",

+ 2 - 1
packages/app/public/static/locales/en_US/translation.json

@@ -387,7 +387,8 @@
     "overwrite_scopes": "{{operation}} and Overwrite scopes of all descendants",
     "notice": {
       "conflict": "Couldn't save the changes you made because someone else was editing this page. Please re-edit the affected section after reloading the page."
-    }
+    },
+    "changes_not_saved": "Changes you made may not be saved."
   },
   "page_comment": {
     "display_the_page_when_posting_this_comment": "Display the page when posting this comment",

+ 2 - 1
packages/app/public/static/locales/ja_JP/translation.json

@@ -387,7 +387,8 @@
     "overwrite_scopes": "{{operation}}と同時に全ての配下ページのスコープを上書き",
     "notice": {
       "conflict": "すでに他の人がこのページを編集していたため保存できませんでした。ページを再読み込み後、自分の編集箇所のみ再度編集してください。"
-    }
+    },
+    "changes_not_saved": "変更が保存されていない可能性があります。"
   },
   "page_comment": {
     "display_the_page_when_posting_this_comment": "投稿時のページを表示する",

+ 2 - 1
packages/app/public/static/locales/zh_CN/translation.json

@@ -366,7 +366,8 @@
 		"overwrite_scopes": "{{operation}和覆盖所有子体的作用域",
 		"notice": {
 			"conflict": "无法保存您所做的更改,因为其他人正在编辑此页。请在重新加载页面后重新编辑受影响的部分。"
-		}
+		},
+    "changes_not_saved": "您所做的更改可能不会保存。"
   },
   "page_comment": {
     "display_the_page_when_posting_this_comment": "Display the page when posting this comment",

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

@@ -18,7 +18,7 @@ import {
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
   useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath, useHasParent,
-  useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader, useIsNotFoundPermalink,
+  useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader,
   useDefaultIndentSize, useIsIndentSizeForced, useCsrfToken, useIsEmptyPage, useEmptyPageId, useGrowiVersion,
 } from '../../stores/context';
 
@@ -92,7 +92,6 @@ const ContextExtractorOnce: FC = () => {
   const revisionAuthor = JSON.parse(mainContent?.getAttribute('data-page-revision-author') || jsonNull);
   const targetAndAncestors = JSON.parse(document.getElementById('growi-pagetree-target-and-ancestors')?.textContent || jsonNull);
   const notFoundTargetPathOrId = JSON.parse(notFoundContentForPt?.getAttribute('data-not-found-target-path-or-id') || jsonNull);
-  const isNotFoundPermalink = JSON.parse(notFoundContext?.getAttribute('data-is-not-found-permalink') || jsonNull);
   const isSearchPage = document.getElementById('search-page') != null;
   const isEmptyPage = JSON.parse(mainContent?.getAttribute('data-page-is-empty') || jsonNull) ?? false;
 
@@ -153,7 +152,6 @@ const ContextExtractorOnce: FC = () => {
   useRevisionAuthor(revisionAuthor);
   useTargetAndAncestors(targetAndAncestors);
   useNotFoundTargetPathOrId(notFoundTargetPathOrId);
-  useIsNotFoundPermalink(isNotFoundPermalink);
   useIsSearchPage(isSearchPage);
   useIsEmptyPage(isEmptyPage);
   useHasParent(hasParent);

+ 0 - 24
packages/app/src/client/services/EditorContainer.js

@@ -21,8 +21,6 @@ export default class EditorContainer extends Container {
       tags: null,
     };
 
-    this.isSetBeforeunloadEventHandler = false;
-
     this.initDrafts();
 
   }
@@ -59,28 +57,6 @@ export default class EditorContainer extends Container {
     }
   }
 
-
-  // See https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload#example
-  showUnsavedWarning(e) {
-    // Cancel the event
-    e.preventDefault();
-    // display browser default message
-    e.returnValue = '';
-    return '';
-  }
-
-  disableUnsavedWarning() {
-    window.removeEventListener('beforeunload', this.showUnsavedWarning);
-    this.isSetBeforeunloadEventHandler = false;
-  }
-
-  enableUnsavedWarning() {
-    if (!this.isSetBeforeunloadEventHandler) {
-      window.addEventListener('beforeunload', this.showUnsavedWarning);
-      this.isSetBeforeunloadEventHandler = true;
-    }
-  }
-
   clearDraft(path) {
     delete this.drafts[path];
     window.localStorage.setItem('drafts', JSON.stringify(this.drafts));

+ 2 - 1
packages/app/src/components/ContentLinkButtons.tsx

@@ -1,9 +1,10 @@
 import React, { useCallback, useMemo } from 'react';
 
-import RecentlyCreatedIcon from './Icons/RecentlyCreatedIcon';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import { usePageUser } from '~/stores/context';
 
+import RecentlyCreatedIcon from './Icons/RecentlyCreatedIcon';
+
 const WIKI_HEADER_LINK = 120;
 
 

+ 6 - 28
packages/app/src/components/NotFoundPage.tsx

@@ -1,40 +1,17 @@
-import React, { useMemo, useEffect } from 'react';
+import React, { useMemo } from 'react';
 
 import { useTranslation } from 'next-i18next';
-import urljoin from 'url-join';
+import dynamic from 'next/dynamic';
 
-import { useIsEmptyPage, useNotFoundTargetPathOrId, useCurrentPathname } from '~/stores/context';
-
-import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
 import { DescendantsPageListForCurrentPath } from './DescendantsPageList';
 import PageListIcon from './Icons/PageListIcon';
 import TimeLineIcon from './Icons/TimeLineIcon';
-import PageTimeline from './PageTimeline';
-
-/**
- * Replace url in address bar with new path and query parameters
- */
-const replaceURLHistory = (path: string) => {
-  const queryParameters = window.location.search;
-  window.history.replaceState(null, '', urljoin(path, queryParameters));
-};
+// import PageTimeline from './PageTimeline';
 
 const NotFoundPage = (): JSX.Element => {
   const { t } = useTranslation();
-  const { data: isEmptyPage } = useIsEmptyPage();
-  const { data: path } = useCurrentPathname();
-  const { data: notFoundTargetPathOrId } = useNotFoundTargetPathOrId();
 
-  // replace url in address bar with path when accessing empty page by permalink
-  useEffect(() => {
-    if (path == null) {
-      return;
-    }
-    const isPermalink = !notFoundTargetPathOrId?.includes('/');
-    if (isEmptyPage && isPermalink) {
-      replaceURLHistory(path);
-    }
-  }, [path, isEmptyPage, notFoundTargetPathOrId]);
+  const CustomNavAndContents = dynamic(() => import('./CustomNavigation/CustomNavAndContents'), { ssr: false });
 
   const navTabMapping = useMemo(() => {
     return {
@@ -46,7 +23,8 @@ const NotFoundPage = (): JSX.Element => {
       },
       timeLine: {
         Icon: TimeLineIcon,
-        Content: PageTimeline,
+        // Content: PageTimeline,
+        Content: () => <></>,
         i18n: t('Timeline View'),
         index: 1,
       },

+ 9 - 6
packages/app/src/components/Page.jsx

@@ -2,7 +2,6 @@ import React, { useEffect, useRef } from 'react';
 
 import PropTypes from 'prop-types';
 
-
 import MarkdownTable from '~/client/models/MarkdownTable';
 import AppContainer from '~/client/services/AppContainer';
 import EditorContainer from '~/client/services/EditorContainer';
@@ -11,7 +10,9 @@ import { getOptionsToSave } from '~/client/util/editor';
 import {
   useCurrentPagePath, useIsGuestUser,
 } from '~/stores/context';
-import { useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors } from '~/stores/editor';
+import {
+  useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
+} from '~/stores/editor';
 import {
   useEditorMode, useIsMobile, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
 } from '~/stores/ui';
@@ -76,7 +77,7 @@ class Page extends React.Component {
 
   async saveHandlerForHandsontableModal(markdownTable) {
     const {
-      isSlackEnabled, slackChannels, pageContainer, editorContainer, grant, grantGroupId, grantGroupName, pageTags,
+      isSlackEnabled, slackChannels, pageContainer, mutateIsEnabledUnsavedWarning, grant, grantGroupId, grantGroupName, pageTags,
     } = this.props;
     const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
 
@@ -89,7 +90,7 @@ class Page extends React.Component {
 
     try {
       // disable unsaved warning
-      editorContainer.disableUnsavedWarning();
+      mutateIsEnabledUnsavedWarning(false);
 
       // eslint-disable-next-line no-unused-vars
       const { page, tags } = await pageContainer.save(newMarkdown, this.props.editorMode, optionsToSave);
@@ -108,7 +109,7 @@ class Page extends React.Component {
 
   async saveHandlerForDrawioModal(drawioData) {
     const {
-      isSlackEnabled, slackChannels, pageContainer, pageTags, grant, grantGroupId, grantGroupName, editorContainer,
+      isSlackEnabled, slackChannels, pageContainer, pageTags, grant, grantGroupId, grantGroupName, mutateIsEnabledUnsavedWarning,
     } = this.props;
     const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
 
@@ -121,7 +122,7 @@ class Page extends React.Component {
 
     try {
       // disable unsaved warning
-      editorContainer.disableUnsavedWarning();
+      mutateIsEnabledUnsavedWarning(false);
 
       // eslint-disable-next-line no-unused-vars
       const { page, tags } = await pageContainer.save(newMarkdown, this.props.editorMode, optionsToSave);
@@ -194,6 +195,7 @@ const PageWrapper = (props) => {
   const { data: grant } = useSelectedGrant();
   const { data: grantGroupId } = useSelectedGrantGroupId();
   const { data: grantGroupName } = useSelectedGrantGroupName();
+  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
 
   const pageRef = useRef(null);
 
@@ -244,6 +246,7 @@ const PageWrapper = (props) => {
       grant={grant}
       grantGroupId={grantGroupId}
       grantGroupName={grantGroupName}
+      mutateIsEnabledUnsavedWarning={mutateIsEnabledUnsavedWarning}
     />
   );
 };

+ 25 - 19
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -2,25 +2,24 @@ 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 { isPopulated } from '~/interfaces/common';
 import {
-  useCurrentPagePath, useIsSharedUser, useIsEditable, useCurrentPageId, useIsUserPage, usePageUser, useShareLinkId, useIsEmptyPage,
+  useCurrentPagePath, useIsSharedUser, useIsEditable, useIsUserPage, usePageUser, useShareLinkId, useIsNotFound,
 } from '~/stores/context';
 import { useDescendantsPageListModal } from '~/stores/modal';
 import { useSWRxCurrentPage } from '~/stores/page';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 
 import CountBadge from '../Common/CountBadge';
-import ContentLinkButtons from '../ContentLinkButtons';
-import HashChanged from '../EventListeneres/HashChanged';
 import PageListIcon from '../Icons/PageListIcon';
-import Page from '../Page';
-import PageEditor from '../PageEditor';
-import EditorNavbarBottom from '../PageEditor/EditorNavbarBottom';
-import PageEditorByHackmd from '../PageEditorByHackmd';
+import NotFoundPage from '../NotFoundPage';
+// import Page from '../Page';
+// import PageEditor from '../PageEditor';
+// import PageEditorByHackmd from '../PageEditorByHackmd';
 import TableOfContents from '../TableOfContents';
 import UserInfo from '../User/UserInfo';
 
@@ -33,34 +32,38 @@ const { isTopPage } = pagePathUtils;
 const DisplaySwitcher = (): JSX.Element => {
   const { t } = useTranslation();
 
+  const EditorNavbarBottom = dynamic(() => import('../PageEditor/EditorNavbarBottom'), { ssr: false });
+  const HashChanged = dynamic(() => import('../EventListeneres/HashChanged'), { ssr: false });
+  const ContentLinkButtons = dynamic(() => import('../ContentLinkButtons'), { ssr: false });
+
   // get element for smoothScroll
-  const getCommentListDom = useMemo(() => { return document.getElementById('page-comments-list') }, []);
+  // const getCommentListDom = useMemo(() => { return document.getElementById('page-comments-list') }, []);
 
-  const { data: isEmptyPage } = useIsEmptyPage();
-  const { data: currentPageId } = useCurrentPageId();
   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: currentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
 
   const { data: editorMode } = useEditorMode();
 
   const { open: openDescendantPageListModal } = useDescendantsPageListModal();
 
-  const isPageExist = currentPageId != null;
   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-reverse">
 
-            { isPageExist && !isEmptyPage && (
+            { !isNotFound && !currentPage?.isEmpty && (
               <div className="grw-side-contents-container">
                 <div className="grw-side-contents-sticky-container">
 
@@ -82,12 +85,13 @@ const DisplaySwitcher = (): JSX.Element => {
                   </div>
 
                   {/* Comments */}
-                  { getCommentListDom != null && !isTopPagePath && (
+                  {/* { getCommentListDom != null && !isTopPagePath && ( */}
+                  { !isTopPagePath && (
                     <div className="grw-page-accessories-control mt-2">
                       <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)}
+                        // onClick={() => smoothScrollIntoView(getCommentListDom, WIKI_HEADER_LINK)}
                       >
                         <i className="icon-fw icon-bubbles grw-page-accessories-control-icon"></i>
                         <span>Comments</span>
@@ -98,7 +102,7 @@ const DisplaySwitcher = (): JSX.Element => {
 
                   <div className="d-none d-lg-block">
                     <div id="revision-toc" className="revision-toc">
-                      <TableOfContents />
+                      {/* <TableOfContents /> */}
                     </div>
                     <ContentLinkButtons />
                   </div>
@@ -109,7 +113,9 @@ const DisplaySwitcher = (): JSX.Element => {
 
             <div className="flex-grow-1 flex-basis-0 mw-0">
               { isUserPage && <UserInfo pageUser={pageUser} />}
-              <Page />
+              {/* { !isNotFound && <Page /> } */}
+              { !isNotFound && revision != null && isPopulated(revision) && revision.body }
+              { isNotFound && <NotFoundPage /> }
             </div>
 
           </div>
@@ -117,14 +123,14 @@ const DisplaySwitcher = (): JSX.Element => {
         { isEditable && (
           <TabPane tabId={EditorMode.Editor}>
             <div data-testid="page-editor" id="page-editor">
-              <PageEditor />
+              {/* <PageEditor /> */}
             </div>
           </TabPane>
         ) }
         { isEditable && (
           <TabPane tabId={EditorMode.HackMD}>
             <div id="page-editor-with-hackmd">
-              <PageEditorByHackmd />
+              {/* <PageEditorByHackmd /> */}
             </div>
           </TabPane>
         ) }

+ 18 - 4
packages/app/src/components/PageEditor.tsx

@@ -18,6 +18,7 @@ import {
 } from '~/stores/context';
 import {
   useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
+  useIsEnabledUnsavedWarning,
 } from '~/stores/editor';
 import {
   EditorMode,
@@ -96,6 +97,7 @@ const PageEditor = (props: Props): JSX.Element => {
   const { data: isTextlintEnabled } = useIsTextlintEnabled();
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
   const { data: indentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
+  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
 
   // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
   const [markdown, setMarkdown] = useState<string>(pageContainer.state.markdown!);
@@ -129,7 +131,7 @@ const PageEditor = (props: Props): JSX.Element => {
 
     try {
       // disable unsaved warning
-      editorContainer.disableUnsavedWarning();
+      mutateIsEnabledUnsavedWarning(false);
 
       // eslint-disable-next-line no-unused-vars
       const { tags } = await pageContainer.save(markdown, editorMode, optionsToSave);
@@ -144,7 +146,19 @@ const PageEditor = (props: Props): JSX.Element => {
       logger.error('failed to save', error);
       pageContainer.showErrorToastr(error);
     }
-  }, [editorContainer, editorMode, grant, grantGroupId, grantGroupName, isSlackEnabled, slackChannelsData, markdown, pageContainer, pageTags]);
+  }, [
+    editorContainer,
+    editorMode,
+    grant,
+    grantGroupId,
+    grantGroupName,
+    isSlackEnabled,
+    slackChannelsData,
+    markdown,
+    pageContainer,
+    pageTags,
+    mutateIsEnabledUnsavedWarning,
+  ]);
 
 
   /**
@@ -355,9 +369,9 @@ const PageEditor = (props: Props): JSX.Element => {
   useEffect(() => {
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     if (pageContainer.state.markdown! !== markdown) {
-      editorContainer.enableUnsavedWarning();
+      mutateIsEnabledUnsavedWarning(true);
     }
-  }, [editorContainer, markdown, pageContainer.state.markdown]);
+  }, [editorContainer, markdown, mutateIsEnabledUnsavedWarning, pageContainer.state.markdown]);
 
   // Detect indent size from contents (only when users are allowed to change it)
   useEffect(() => {

+ 4 - 5
packages/app/src/components/PageEditor/EditorNavbarBottom.tsx

@@ -4,9 +4,8 @@ import PropTypes from 'prop-types';
 import { Collapse, Button } from 'reactstrap';
 
 
-import AppContainer from '~/client/services/AppContainer';
 import EditorContainer from '~/client/services/EditorContainer';
-import { useCurrentPagePath } from '~/stores/context';
+import { useCurrentPagePath, useIsSlackConfigured } from '~/stores/context';
 import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
 import {
   EditorMode, useDrawerOpened, useEditorMode, useIsDeviceSmallerThanMd,
@@ -27,13 +26,14 @@ const EditorNavbarBottom = (props) => {
   const [isExpanded, setExpanded] = useState(false);
 
   const [isSlackExpanded, setSlackExpanded] = useState(false);
-  const isSlackConfigured = props.appContainer.getConfig().isSlackConfigured;
 
+  const { data: isSlackConfigured } = useIsSlackConfigured();
   const { mutate: mutateDrawerOpened } = useDrawerOpened();
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
   const additionalClasses = ['grw-editor-navbar-bottom'];
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
+
   const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
 
   const [slackChannelsStr, setSlackChannelsStr] = useState<string>('');
@@ -153,8 +153,7 @@ const EditorNavbarBottom = (props) => {
 };
 
 EditorNavbarBottom.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 };
 
-export default withUnstatedContainers(EditorNavbarBottom, [EditorContainer, AppContainer]);
+export default withUnstatedContainers(EditorNavbarBottom, [EditorContainer]);

+ 4 - 20
packages/app/src/components/PageEditor/OptionsSelector.tsx

@@ -7,12 +7,10 @@ import {
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
 
-import AppContainer from '~/client/services/AppContainer';
 import { useIsIndentSizeForced } from '~/stores/context';
 import { useEditorSettings, useIsTextlintEnabled, useCurrentIndentSize } from '~/stores/editor';
 
 import { DEFAULT_THEME, KeyMapMode } from '../../interfaces/editor-settings';
-import { withUnstatedContainers } from '../UnstatedUtils';
 
 import { DownloadDictModal } from './DownloadDictModal';
 
@@ -165,11 +163,10 @@ IndentSizeSelector.displayName = 'IndentSizeSelector';
 
 
 type ConfigurationDropdownProps = {
-  isMathJaxEnabled: boolean,
   onConfirmEnableTextlint?: () => void,
 }
 
-const ConfigurationDropdown = memo(({ isMathJaxEnabled, onConfirmEnableTextlint }: ConfigurationDropdownProps): JSX.Element => {
+const ConfigurationDropdown = memo(({ onConfirmEnableTextlint }: ConfigurationDropdownProps): JSX.Element => {
   const { t } = useTranslation();
 
   const [isCddMenuOpened, setCddMenuOpened] = useState(false);
@@ -207,10 +204,6 @@ const ConfigurationDropdown = memo(({ isMathJaxEnabled, onConfirmEnableTextlint
       return <></>;
     }
 
-    if (!isMathJaxEnabled) {
-      return <></>;
-    }
-
     const isActive = editorSettings.renderMathJaxInRealtime;
 
     const iconClasses = ['text-info'];
@@ -228,7 +221,7 @@ const ConfigurationDropdown = memo(({ isMathJaxEnabled, onConfirmEnableTextlint
         </div>
       </DropdownItem>
     );
-  }, [editorSettings, isMathJaxEnabled, update]);
+  }, [editorSettings, update]);
 
   const renderRealtimeDrawioMenuItem = useCallback(() => {
     if (editorSettings == null) {
@@ -347,14 +340,7 @@ const ConfigurationDropdown = memo(({ isMathJaxEnabled, onConfirmEnableTextlint
 ConfigurationDropdown.displayName = 'ConfigurationDropdown';
 
 
-type Props = {
-  appContainer: AppContainer
-};
-
-const OptionsSelector = (props: Props): JSX.Element => {
-  const { appContainer } = props;
-  const config = appContainer.config;
-
+const OptionsSelector = (): JSX.Element => {
   const [isDownloadDictModalShown, setDownloadDictModalShown] = useState(false);
 
   const { data: editorSettings, turnOffAskingBeforeDownloadLargeFiles } = useEditorSettings();
@@ -384,7 +370,6 @@ const OptionsSelector = (props: Props): JSX.Element => {
         </span>
         <span className="ml-2 ml-sm-4">
           <ConfigurationDropdown
-            isMathJaxEnabled={!!config.env.MATHJAX}
             onConfirmEnableTextlint={() => setDownloadDictModalShown(true)}
           />
         </span>
@@ -411,5 +396,4 @@ const OptionsSelector = (props: Props): JSX.Element => {
 };
 
 
-const OptionsSelectorWrapper = withUnstatedContainers(OptionsSelector, [AppContainer]);
-export default OptionsSelectorWrapper;
+export default OptionsSelector;

+ 10 - 6
packages/app/src/components/PageEditorByHackmd.jsx

@@ -3,14 +3,15 @@ import React from 'react';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 
-
 import AppContainer from '~/client/services/AppContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import PageContainer from '~/client/services/PageContainer';
 import { apiPost } from '~/client/util/apiv1-client';
 import { getOptionsToSave } from '~/client/util/editor';
 import { useCurrentPagePath, useCurrentPageId } from '~/stores/context';
-import { useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors } from '~/stores/editor';
+import {
+  useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
+} from '~/stores/editor';
 import {
   useEditorMode, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
 } from '~/stores/ui';
@@ -172,13 +173,13 @@ class PageEditorByHackmd extends React.Component {
    */
   async onSaveWithShortcut(markdown) {
     const {
-      isSlackEnabled, slackChannels, pageContainer, editorContainer, grant, grantGroupId, grantGroupName, pageTags,
+      isSlackEnabled, slackChannels, pageContainer, editorContainer, grant, grantGroupId, grantGroupName, pageTags, mutateIsEnabledUnsavedWarning,
     } = this.props;
     const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
 
     try {
       // disable unsaved warning
-      editorContainer.disableUnsavedWarning();
+      mutateIsEnabledUnsavedWarning(false);
 
       // eslint-disable-next-line no-unused-vars
       const { page, tags } = await pageContainer.save(markdown, this.props.editorMode, optionsToSave);
@@ -200,7 +201,7 @@ class PageEditorByHackmd extends React.Component {
    */
   async hackmdEditorChangeHandler(body) {
     const hackmdUri = this.getHackmdUri();
-    const { pageContainer, editorContainer } = this.props;
+    const { pageContainer, mutateIsEnabledUnsavedWarning } = this.props;
 
     if (hackmdUri == null) {
       // do nothing
@@ -213,7 +214,7 @@ class PageEditorByHackmd extends React.Component {
     }
 
     // enable unsaved warning
-    editorContainer.enableUnsavedWarning();
+    mutateIsEnabledUnsavedWarning(true);
 
     const params = {
       pageId: pageContainer.state.pageId,
@@ -439,6 +440,7 @@ PageEditorByHackmd.propTypes = {
   grant: PropTypes.number.isRequired,
   grantGroupId: PropTypes.string,
   grantGroupName: PropTypes.string,
+  mutateIsEnabledUnsavedWarning: PropTypes.func,
 };
 
 /**
@@ -457,6 +459,7 @@ const PageEditorByHackmdWrapper = (props) => {
   const { data: grant } = useSelectedGrant();
   const { data: grantGroupId } = useSelectedGrantGroupId();
   const { data: grantGroupName } = useSelectedGrantGroupName();
+  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
 
   if (editorMode == null) {
     return null;
@@ -473,6 +476,7 @@ const PageEditorByHackmdWrapper = (props) => {
       grant={grant}
       grantGroupId={grantGroupId}
       grantGroupName={grantGroupName}
+      mutateIsEnabledUnsavedWarning={mutateIsEnabledUnsavedWarning}
     />
   );
 };

+ 4 - 0
packages/app/src/components/PrivateLegacyPages.tsx

@@ -128,6 +128,8 @@ const SearchResultListHead = React.memo((props: SearchResultListHeadProps): JSX.
   );
 });
 
+SearchResultListHead.displayName = 'SearchResultListHead';
+
 /*
  * ConvertByPathModal
  */
@@ -182,6 +184,8 @@ const ConvertByPathModal = React.memo((props: ConvertByPathModalProps): JSX.Elem
   );
 });
 
+ConvertByPathModal.displayName = 'ConvertByPathModal';
+
 /**
  * LegacyPage
  */

+ 16 - 20
packages/app/src/components/SavePageControls.jsx

@@ -7,15 +7,10 @@ import {
   DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
 
-
-import AppContainer from '~/client/services/AppContainer';
-import EditorContainer from '~/client/services/EditorContainer';
 import PageContainer from '~/client/services/PageContainer';
 import { getOptionsToSave } from '~/client/util/editor';
-
-// TODO: remove this when omitting unstated is completed
-import { useIsEditable, useCurrentPageId } from '~/stores/context';
-import { usePageTagsForEditors } from '~/stores/editor';
+import { useIsEditable, useCurrentPageId, useIsAclEnabled } from '~/stores/context';
+import { usePageTagsForEditors, useIsEnabledUnsavedWarning } from '~/stores/editor';
 import {
   useEditorMode, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
 } from '~/stores/ui';
@@ -31,9 +26,6 @@ class SavePageControls extends React.Component {
   constructor(props) {
     super(props);
 
-    const config = this.props.appContainer.getConfig();
-    this.isAclEnabled = config.isAclEnabled;
-
     this.updateGrantHandler = this.updateGrantHandler.bind(this);
 
     this.save = this.save.bind(this);
@@ -51,10 +43,10 @@ class SavePageControls extends React.Component {
 
   async save() {
     const {
-      isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageContainer, editorContainer, pageTags,
+      isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageContainer, pageTags, mutateIsEnabledUnsavedWarning,
     } = this.props;
     // disable unsaved warning
-    editorContainer.disableUnsavedWarning();
+    mutateIsEnabledUnsavedWarning(false);
 
     try {
       // save
@@ -77,10 +69,10 @@ class SavePageControls extends React.Component {
 
   saveAndOverwriteScopesOfDescendants() {
     const {
-      isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageContainer, editorContainer, pageTags,
+      isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageContainer, pageTags, mutateIsEnabledUnsavedWarning,
     } = this.props;
     // disable unsaved warning
-    editorContainer.disableUnsavedWarning();
+    mutateIsEnabledUnsavedWarning(false);
     // save
     const currentOptionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
     const optionsToSave = Object.assign(currentOptionsToSave, {
@@ -92,7 +84,7 @@ class SavePageControls extends React.Component {
   render() {
 
     const {
-      t, pageContainer, grant, grantGroupId, grantGroupName,
+      t, pageContainer, isAclEnabled, grant, grantGroupId, grantGroupName,
     } = this.props;
 
     const isRootPage = pageContainer.state.path === '/';
@@ -102,7 +94,7 @@ class SavePageControls extends React.Component {
     return (
       <div className="d-flex align-items-center form-inline flex-nowrap">
 
-        {this.isAclEnabled
+        {isAclEnabled
           && (
             <div className="mr-2">
               <GrantSelector
@@ -135,20 +127,22 @@ class SavePageControls extends React.Component {
 /**
  * Wrapper component for using unstated
  */
-const SavePageControlsHOCWrapper = withUnstatedContainers(SavePageControls, [AppContainer, PageContainer, EditorContainer]);
+const SavePageControlsHOCWrapper = withUnstatedContainers(SavePageControls, [PageContainer]);
 
 const SavePageControlsWrapper = (props) => {
   const { t } = useTranslation();
   const { data: isEditable } = useIsEditable();
   const { data: editorMode } = useEditorMode();
+  const { data: isAclEnabled } = useIsAclEnabled();
   const { data: grant, mutate: mutateGrant } = useSelectedGrant();
   const { data: grantGroupId, mutate: mutateGrantGroupId } = useSelectedGrantGroupId();
   const { data: grantGroupName, mutate: mutateGrantGroupName } = useSelectedGrantGroupName();
   const { data: pageId } = useCurrentPageId();
   const { data: pageTags } = usePageTagsForEditors(pageId);
+  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
 
 
-  if (isEditable == null || editorMode == null) {
+  if (isEditable == null || editorMode == null || isAclEnabled == null) {
     return null;
   }
 
@@ -161,12 +155,14 @@ const SavePageControlsWrapper = (props) => {
       t={t}
       {...props}
       editorMode={editorMode}
+      isAclEnabled={isAclEnabled}
       grant={grant}
       grantGroupId={grantGroupId}
       grantGroupName={grantGroupName}
       mutateGrant={mutateGrant}
       mutateGrantGroupId={mutateGrantGroupId}
       mutateGrantGroupName={mutateGrantGroupName}
+      mutateIsEnabledUnsavedWarning={mutateIsEnabledUnsavedWarning}
       pageTags={pageTags}
     />
   );
@@ -175,21 +171,21 @@ const SavePageControlsWrapper = (props) => {
 SavePageControls.propTypes = {
   t: PropTypes.func.isRequired, // i18next
 
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 
   // TODO: remove this when omitting unstated is completed
   editorMode: PropTypes.string.isRequired,
   isSlackEnabled: PropTypes.bool.isRequired,
   slackChannels: PropTypes.string.isRequired,
   pageTags: PropTypes.arrayOf(PropTypes.string),
+  isAclEnabled: PropTypes.bool.isRequired,
   grant: PropTypes.number.isRequired,
   grantGroupId: PropTypes.string,
   grantGroupName: PropTypes.string,
   mutateGrant: PropTypes.func,
   mutateGrantGroupId: PropTypes.func,
   mutateGrantGroupName: PropTypes.func,
+  mutateIsEnabledUnsavedWarning: PropTypes.func,
 };
 
 export default SavePageControlsWrapper;

+ 22 - 22
packages/app/src/components/StickyStretchableScroller.tsx

@@ -2,9 +2,9 @@ import React, {
   useEffect, useCallback, ReactNode, useRef, useState, useMemo, RefObject,
 } from 'react';
 
-import { debounce } from 'throttle-debounce';
-import StickyEvents from 'sticky-events';
 import SimpleBar from 'simplebar-react';
+import StickyEvents from 'sticky-events';
+import { debounce } from 'throttle-debounce';
 
 import loggerFactory from '~/utils/logger';
 
@@ -70,26 +70,26 @@ export const StickyStretchableScroller = (props: StickyStretchableScrollerProps)
 
   const resetScrollbarDebounced = useMemo(() => debounce(100, resetScrollbar), [resetScrollbar]);
 
-  const stickyChangeHandler = useCallback(() => {
-    logger.debug('StickyEvents.CHANGE detected');
-    resetScrollbarDebounced();
-  }, [resetScrollbarDebounced]);
-
-  // setup effect by sticky event
-  useEffect(() => {
-    // sticky
-    // See: https://github.com/ryanwalters/sticky-events
-    const stickyEvents = new StickyEvents({ stickySelector: stickyElemSelector });
-    stickyEvents.enableEvents();
-    const { stickySelector } = stickyEvents;
-    const elem = document.querySelector(stickySelector);
-    elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
-
-    // return clean up handler
-    return () => {
-      elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
-    };
-  }, [stickyElemSelector, stickyChangeHandler]);
+  // const stickyChangeHandler = useCallback(() => {
+  //   logger.debug('StickyEvents.CHANGE detected');
+  //   resetScrollbarDebounced();
+  // }, [resetScrollbarDebounced]);
+
+  // // setup effect by sticky event
+  // useEffect(() => {
+  //   // sticky
+  //   // See: https://github.com/ryanwalters/sticky-events
+  //   const stickyEvents = new StickyEvents({ stickySelector: stickyElemSelector });
+  //   stickyEvents.enableEvents();
+  //   const { stickySelector } = stickyEvents;
+  //   const elem = document.querySelector(stickySelector);
+  //   elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+
+  //   // return clean up handler
+  //   return () => {
+  //     elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+  //   };
+  // }, [stickyElemSelector, stickyChangeHandler]);
 
   // setup effect by resizing event
   useEffect(() => {

+ 2 - 0
packages/app/src/components/TagCloudBox.tsx

@@ -37,6 +37,8 @@ const TagCloudBox: FC<Props> = memo((props:(Props & typeof defaultProps)) => {
 
 });
 
+TagCloudBox.displayName = 'withLoadingSppiner';
+
 TagCloudBox.defaultProps = defaultProps;
 
 export default TagCloudBox;

+ 2 - 0
packages/app/src/components/UncontrolledCodeMirror.tsx

@@ -53,3 +53,5 @@ export const UncontrolledCodeMirror = forwardRef<UncontrolledCodeMirrorCore, Unc
     />
   );
 });
+
+UncontrolledCodeMirror.displayName = 'UncontrolledCodeMirror';

+ 0 - 5
packages/app/src/interfaces/page-listing-results.ts

@@ -23,11 +23,6 @@ export interface TargetAndAncestors {
 }
 
 
-export interface IsNotFoundPermalink {
-  isNotFoundPermalink: boolean
-}
-
-
 export interface V5MigrationStatus {
   isV5Compatible : boolean,
   migratablePagesCount: number

+ 59 - 0
packages/app/src/pages/UnsavedAlertDialog.tsx

@@ -0,0 +1,59 @@
+import React, { useCallback, useEffect } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import { useRouter } from 'next/router';
+
+import { useIsEnabledUnsavedWarning } from '~/stores/editor';
+
+const UnsavedAlertDialog = (): JSX.Element => {
+  const { t } = useTranslation();
+  const router = useRouter();
+  const { data: isEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
+
+  const alertUnsavedWarningByBrowser = useCallback((e) => {
+    if (isEnabledUnsavedWarning) {
+      e.preventDefault();
+      // returnValue should be set to show alert dialog
+      // default alert message cannot be changed.
+      // See -> https://developer.mozilla.org/ja/docs/Web/API/Window/beforeunload_event
+      e.returnValue = '';
+      return;
+    }
+  }, [isEnabledUnsavedWarning]);
+
+  const alertUnsavedWarningByNextRouter = useCallback(() => {
+    if (isEnabledUnsavedWarning) {
+    // eslint-disable-next-line no-alert
+      window.alert(t('page_edit.changes_not_saved'));
+    }
+    return;
+  }, [isEnabledUnsavedWarning, t]);
+
+  /*
+  * Route changes by Browser
+  * Example: window.location.href, F5
+  */
+  useEffect(() => {
+    window.addEventListener('beforeunload', alertUnsavedWarningByBrowser);
+    return () => {
+      window.removeEventListener('beforeunload', alertUnsavedWarningByBrowser);
+    };
+  }, [alertUnsavedWarningByBrowser]);
+
+
+  /*
+  * Route changes by Next Router
+  * https://nextjs.org/docs/api-reference/next/router
+  */
+  useEffect(() => {
+    router.events.on('routeChangeStart', alertUnsavedWarningByNextRouter);
+    return () => {
+      router.events.off('routeChangeStart', alertUnsavedWarningByNextRouter);
+    };
+  }, [alertUnsavedWarningByNextRouter, router.events]);
+
+
+  return <></>;
+};
+
+export default UnsavedAlertDialog;

+ 84 - 35
packages/app/src/pages/[[...path]].page.tsx

@@ -1,16 +1,17 @@
 import React, { useEffect } from 'react';
 
-import { pagePathUtils } from '@growi/core';
-import { isValidObjectId } from 'mongoose';
+import { isClient, pagePathUtils, pathUtils } from '@growi/core';
 import {
   NextPage, GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
+import dynamic from 'next/dynamic';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 
 // import { PageAlerts } from '~/components/PageAlert/PageAlerts';
 // import { PageComments } from '~/components/PageComment/PageComments';
 // import { useTranslation } from '~/i18n';
+import { isPopulated } from '~/interfaces/common';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 // import { renderScriptTagByName, renderHighlightJsStyleTag } from '~/service/cdn-resources-loader';
 // import { useIndentSize } from '~/stores/editor';
@@ -18,7 +19,7 @@ import { CrowiRequest } from '~/interfaces/crowi-request';
 // import { EditorMode, useEditorMode, useIsMobile } from '~/stores/ui';
 import { IPageWithMeta } from '~/interfaces/page';
 import { ISidebarConfig } from '~/interfaces/sidebar-config';
-import { PageModel } from '~/server/models/page';
+import { PageModel, PageDocument } from '~/server/models/page';
 import { serializeUserSecurely } from '~/server/models/serializers/user-serializer';
 import UserUISettings, { UserUISettingsDocument } from '~/server/models/user-ui-settings';
 import { useSWRxCurrentPage, useSWRxPageInfo } from '~/stores/page';
@@ -31,8 +32,8 @@ import loggerFactory from '~/utils/logger';
 
 // import GrowiSubNavigation from '../client/js/components/Navbar/GrowiSubNavigation';
 // import GrowiSubNavigationSwitcher from '../client/js/components/Navbar/GrowiSubNavigationSwitcher';
-// import DisplaySwitcher from '../client/js/components/Page/DisplaySwitcher';
 import { BasicLayout } from '../components/BasicLayout';
+import DisplaySwitcher from '../components/Page/DisplaySwitcher';
 
 // import { serializeUserSecurely } from '../server/models/serializers/user-serializer';
 // import PageStatusAlert from '../client/js/components/PageStatusAlert';
@@ -44,7 +45,8 @@ import {
   useIsForbidden, useIsNotFound, useIsTrashPage, useShared, useShareLinkId, useIsSharedUser, useIsAbleToDeleteCompletely,
   useAppTitle, useSiteUrl, useConfidential, useIsEnabledStaleNotification,
   useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsMailerSetup,
-  useAclEnabled, useHasSlackConfig, useDrawioUri, useHackmdUri, useMathJax, useNoCdn, useEditorConfig, useCsrfToken, useIsSearchScopeChildrenAsDefault, useCurrentPageId, useCurrentPathname,
+  useIsAclEnabled, useHasSlackConfig, useDrawioUri, useHackmdUri, useMathJax,
+  useNoCdn, useEditorConfig, useCsrfToken, useIsSearchScopeChildrenAsDefault, useCurrentPageId, useCurrentPathname, useIsSlackConfigured,
 } from '../stores/context';
 
 import { CommonProps, getServerSideCommonProps, useCustomTitle } from './commons';
@@ -52,7 +54,8 @@ import { CommonProps, getServerSideCommonProps, useCustomTitle } from './commons
 
 
 const logger = loggerFactory('growi:pages:all');
-const { isUsersHomePage, isTrashPage: _isTrashPage } = pagePathUtils;
+const { isPermalink: _isPermalink, isUsersHomePage, isTrashPage: _isTrashPage } = pagePathUtils;
+const { removeHeadingSlash } = pathUtils;
 
 type Props = CommonProps & {
   currentUser: string,
@@ -67,11 +70,14 @@ type Props = CommonProps & {
   isForbidden: boolean,
   isNotFound: boolean,
   // isAbleToDeleteCompletely: boolean,
+
   isSearchServiceConfigured: boolean,
   isSearchServiceReachable: boolean,
   isSearchScopeChildrenAsDefault: boolean,
+
+  isSlackConfigured: boolean,
   // isMailerSetup: boolean,
-  // isAclEnabled: boolean,
+  isAclEnabled: boolean,
   // hasSlackConfig: boolean,
   // drawioUri: string,
   // hackmdUri: string,
@@ -97,6 +103,8 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   // const { t } = useTranslation();
   const router = useRouter();
 
+  const UnsavedAlertDialog = dynamic(() => import('./UnsavedAlertDialog'), { ssr: false });
+
   const { data: currentUser } = useCurrentUser(props.currentUser != null ? JSON.parse(props.currentUser) : null);
 
   // commons
@@ -116,8 +124,8 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   // page
   useCurrentPagePath(props.currentPathname);
   // useOwnerOfCurrentPage(props.pageUser != null ? JSON.parse(props.pageUser) : null);
-  // useIsForbidden(props.isForbidden);
-  // useNotFound(props.isNotFound);
+  useIsForbidden(props.isForbidden);
+  useIsNotFound(props.isNotFound);
   // useIsTrashPage(_isTrashPage(props.currentPagePath));
   // useShared();
   // useShareLinkId(props.shareLinkId);
@@ -128,8 +136,10 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   useIsSearchServiceConfigured(props.isSearchServiceConfigured);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
+
+  useIsSlackConfigured(props.isSlackConfigured);
   // useIsMailerSetup(props.isMailerSetup);
-  // useAclEnabled(props.isAclEnabled);
+  useIsAclEnabled(props.isAclEnabled);
   // useHasSlackConfig(props.hasSlackConfig);
   // useDrawioUri(props.drawioUri);
   // useHackmdUri(props.hackmdUri);
@@ -156,6 +166,13 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   useCurrentPagePath(pageWithMeta?.data.path);
   useCurrentPathname(props.currentPathname);
 
+  // sync pathname by Shallow Routing https://nextjs.org/docs/routing/shallow-routing
+  useEffect(() => {
+    if (isClient() && window.location.pathname !== props.currentPathname) {
+      router.replace(props.currentPathname, undefined, { shallow: true });
+    }
+  }, [props.currentPathname, router]);
+
   const classNames: string[] = [];
   // switch (editorMode) {
   //   case EditorMode.Editor:
@@ -172,15 +189,6 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   //   classNames.push('not-found-page');
   // }
 
-
-  // // Rewrite browser url by Shallow Routing https://nextjs.org/docs/routing/shallow-routing
-  // useEffect(() => {
-  //   if (props.redirectTo != null) {
-  //     router.push('/[[...path]]', props.redirectTo, { shallow: true });
-  //   }
-  // // eslint-disable-next-line react-hooks/exhaustive-deps
-  // }, []);
-
   return (
     <>
       <Head>
@@ -212,8 +220,7 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
               <div id="content-main" className="content-main grw-container-convertible">
                 {/* <PageAlerts /> */}
                 PageAlerts<br />
-                {/* <DisplaySwitcher /> */}
-                DisplaySwitcher<br />
+                <DisplaySwitcher />
                 <div id="page-editor-navbar-bottom-container" className="d-none d-edit-block"></div>
                 {/* <PageStatusAlert /> */}
                 PageStatusAlert
@@ -233,38 +240,74 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
           PageComments
         </footer>
 
+        <UnsavedAlertDialog />
+
       </BasicLayout>
     </>
   );
 };
 
-async function injectPageInformation(context: GetServerSidePropsContext, props: Props): Promise<void> {
+
+function getPageIdFromPathname(currentPathname: string): string | null {
+  return _isPermalink(currentPathname) ? removeHeadingSlash(currentPathname) : null;
+}
+
+async function getPageData(context: GetServerSidePropsContext, props: Props): Promise<IPageWithMeta|null> {
   const req: CrowiRequest = context.req as CrowiRequest;
   const { crowi } = req;
-  const Page = crowi.model('Page');
+  const { revisionId } = req.query;
   const { pageService } = crowi;
 
   const { user } = req;
 
   const { currentPathname } = props;
-
-  // determine pageId
-  const pageIdStr = currentPathname.substring(1);
-  const pageId = isValidObjectId(pageIdStr) ? pageIdStr : null;
+  const pageId = getPageIdFromPathname(currentPathname);
 
   const result: IPageWithMeta = await pageService.findPageAndMetaDataByViewer(pageId, currentPathname, user, true); // includeEmpty = true, isSharedPage = false
-  const page = result.data;
+  const page = result?.data as unknown as PageDocument;
+
+  // populate
+  if (page != null) {
+    page.initLatestRevisionField(revisionId);
+    await page.populateDataToShowRevision();
+  }
+
+  return result;
+}
+
+async function injectRoutingInformation(context: GetServerSidePropsContext, props: Props, pageWithMeta: IPageWithMeta|null): Promise<void> {
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const { crowi } = req;
+  const Page = crowi.model('Page');
+
+  const { currentPathname } = props;
+  const pageId = getPageIdFromPathname(currentPathname);
+  const isPermalink = _isPermalink(currentPathname);
+
+  const page = pageWithMeta?.data;
 
   if (page == null) {
-    const count = pageId != null ? await Page.count({ _id: pageId }) : await Page.count({ path: currentPathname });
+    props.isNotFound = true;
+
     // check the page is forbidden or just does not exist.
+    const count = isPermalink ? await Page.count({ _id: pageId }) : await Page.count({ path: currentPathname });
     props.isForbidden = count > 0;
-    props.isNotFound = true;
-    logger.warn(`Page is ${props.isForbidden ? 'forbidden' : 'not found'}`, currentPathname);
   }
 
-  await (page as unknown as PageModel).populateDataToShowRevision();
-  props.pageWithMetaStr = JSON.stringify(result);
+  if (page != null) {
+    // /62a88db47fed8b2d94f30000 ==> /path/to/page
+    if (isPermalink && page.isEmpty) {
+      props.currentPathname = page.path;
+    }
+
+    // /path/to/page ==> /62a88db47fed8b2d94f30000
+    if (!isPermalink && !page.isEmpty) {
+      const isToppage = pagePathUtils.isTopPage(props.currentPathname);
+      if (!isToppage) {
+        props.currentPathname = `/${page._id}`;
+      }
+    }
+  }
 }
 
 // async function injectPageUserInformation(context: GetServerSidePropsContext, props: Props): Promise<void> {
@@ -300,7 +343,11 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
   }
 
   const props: Props = result.props as Props;
-  await injectPageInformation(context, props);
+  const pageWithMeta = await getPageData(context, props);
+
+  props.pageWithMetaStr = JSON.stringify(pageWithMeta);
+
+  injectRoutingInformation(context, props, pageWithMeta);
 
   if (user != null) {
     props.currentUser = JSON.stringify(user);
@@ -309,8 +356,10 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
   props.isSearchServiceConfigured = searchService.isConfigured;
   props.isSearchServiceReachable = searchService.isReachable;
   props.isSearchScopeChildrenAsDefault = configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
+
+  props.isSlackConfigured = crowi.slackIntegrationService.isSlackConfigured;
   // props.isMailerSetup = mailService.isMailerSetup;
-  // props.isAclEnabled = aclService.isAclEnabled();
+  props.isAclEnabled = aclService.isAclEnabled();
   // props.hasSlackConfig = slackNotificationService.hasSlackConfig();
   // props.drawioUri = configManager.getConfig('crowi', 'app:drawioUri');
   // props.hackmdUri = configManager.getConfig('crowi', 'app:hackmdUri');

+ 5 - 0
packages/app/src/server/interfaces/search.ts

@@ -36,6 +36,11 @@ export type SearchableData<T = Partial<QueryTerms>> = {
   terms: T
 }
 
+export type UpdateOrInsertPagesOpts = {
+  shouldEmitProgress?: boolean
+  invokeGarbageCollection?: boolean
+}
+
 // Terms Key types
 export type AllTermsKey = keyof QueryTerms;
 export type UnavailableTermsKey<K extends AllTermsKey> = Exclude<AllTermsKey, K>;

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

@@ -12,18 +12,6 @@ module.exports = (crowi, isGuestAllowed = false, fallback = null) => {
 
   return function(req, res, next) {
 
-    // check the route config and ACL
-    if (isGuestAllowed && crowi.aclService.isGuestAllowedToRead()) {
-      logger.debug('Allowed to read: ', req.path);
-      return next();
-    }
-
-    // check the page is shared
-    if (isGuestAllowed && req.isSharedPage) {
-      logger.debug('Target page is shared page');
-      return next();
-    }
-
     const User = crowi.model('User');
 
     // check the user logged in
@@ -43,6 +31,18 @@ module.exports = (crowi, isGuestAllowed = false, fallback = null) => {
       }
     }
 
+    // check the route config and ACL
+    if (isGuestAllowed && crowi.aclService.isGuestAllowedToRead()) {
+      logger.debug('Allowed to read: ', req.path);
+      return next();
+    }
+
+    // check the page is shared
+    if (isGuestAllowed && req.isSharedPage) {
+      logger.debug('Target page is shared page');
+      return next();
+    }
+
     // is api path
     const baseUrl = req.baseUrl || '';
     if (baseUrl.match(/^\/_api\/.+$/)) {

+ 1 - 1
packages/app/src/server/interfaces/page-operation.ts → packages/app/src/server/models/interfaces/page-operation.ts

@@ -1,4 +1,4 @@
-import { ObjectIdLike } from './mongoose-utils';
+import { ObjectIdLike } from '../../interfaces/mongoose-utils';
 
 export type IPageForResuming = {
   _id: ObjectIdLike,

+ 1 - 1
packages/app/src/server/models/page-operation.ts

@@ -6,7 +6,7 @@ import mongoose, {
 
 import {
   IPageForResuming, IUserForResuming, IOptionsForResuming,
-} from '~/server/interfaces/page-operation';
+} from '~/server/models/interfaces/page-operation';
 
 import loggerFactory from '../../utils/logger';
 import { ObjectIdLike } from '../interfaces/mongoose-utils';

+ 1 - 2
packages/app/src/server/routes/login.js

@@ -217,8 +217,7 @@ module.exports = function(crowi, app) {
       }
     }
     else {
-      return res.render('invited', {
-      });
+      return res.render('invited');
     }
   };
 

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

@@ -170,7 +170,7 @@ module.exports = function(crowi, app) {
   const actions = {};
 
   function getPathFromRequest(req) {
-    return pathUtils.normalizePath(req.pagePath || req.params[0] || '');
+    return pathUtils.normalizePath(req.pagePath || req.params[0] || req.params.id || '');
   }
 
   function generatePager(offset, limit, totalCount) {
@@ -274,9 +274,6 @@ module.exports = function(crowi, app) {
     }
 
     renderVars.notFoundTargetPathOrId = pathOrId;
-
-    const isPath = pathOrId.includes('/');
-    renderVars.isNotFoundPermalink = !isPath && !await Page.exists({ _id: pathOrId });
   }
 
   async function addRenderVarsWhenEmptyPage(renderVars, isEmpty, pageId) {

+ 4 - 0
packages/app/src/server/service/interfaces/search.ts

@@ -0,0 +1,4 @@
+export type UpdateOrInsertPagesOpts = {
+  shouldEmitProgress?: boolean
+  invokeGarbageCollection?: boolean
+}

+ 15 - 6
packages/app/src/server/service/page.ts

@@ -2713,7 +2713,7 @@ class PageService {
 
     // then migrate
     try {
-      await this.normalizeParentRecursively(['/'], null);
+      await this.normalizeParentRecursively(['/'], null, true);
     }
     catch (err) {
       logger.error('V5 initial miration failed.', err);
@@ -2760,7 +2760,7 @@ class PageService {
    * @param user To be used to filter pages to update. If null, only public pages will be updated.
    * @returns Promise<void>
    */
-  async normalizeParentRecursively(paths: string[], user: any | null, shouldEmit = false): Promise<number> {
+  async normalizeParentRecursively(paths: string[], user: any | null, shouldEmitProgress = false): Promise<number> {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
     const ancestorPaths = paths.flatMap(p => collectAncestorPaths(p, []));
@@ -2779,7 +2779,7 @@ class PageService {
 
     const grantFiltersByUser: { $or: any[] } = Page.generateGrantCondition(user, userGroups);
 
-    return this._normalizeParentRecursively(pathAndRegExpsToNormalize, ancestorPaths, grantFiltersByUser, user, shouldEmit);
+    return this._normalizeParentRecursively(pathAndRegExpsToNormalize, ancestorPaths, grantFiltersByUser, user, shouldEmitProgress);
   }
 
   private buildFilterForNormalizeParentRecursively(pathOrRegExps: (RegExp | string)[], publicPathsToNormalize: string[], grantFiltersByUser: { $or: any[] }) {
@@ -2829,7 +2829,7 @@ class PageService {
       publicPathsToNormalize: string[],
       grantFiltersByUser: { $or: any[] },
       user,
-      shouldEmit = false,
+      shouldEmitProgress = false,
       count = 0,
       skiped = 0,
       isFirst = true,
@@ -2837,7 +2837,7 @@ class PageService {
     const BATCH_SIZE = 100;
     const PAGES_LIMIT = 1000;
 
-    const socket = shouldEmit ? this.crowi.socketIoService.getAdminSocket() : null;
+    const socket = shouldEmitProgress ? this.crowi.socketIoService.getAdminSocket() : null;
 
     const Page = mongoose.model('Page') as unknown as PageModel;
     const { PageQueryBuilder } = Page;
@@ -3002,7 +3002,16 @@ class PageService {
     await streamToPromise(migratePagesStream);
 
     if (await Page.exists(matchFilter) && shouldContinue) {
-      return this._normalizeParentRecursively(pathOrRegExps, publicPathsToNormalize, grantFiltersByUser, user, shouldEmit, nextCount, nextSkiped, false);
+      return this._normalizeParentRecursively(
+        pathOrRegExps,
+        publicPathsToNormalize,
+        grantFiltersByUser,
+        user,
+        shouldEmitProgress,
+        nextCount,
+        nextSkiped,
+        false,
+      );
     }
 
     // End

+ 9 - 9
packages/app/src/server/service/search-delegator/elasticsearch.ts

@@ -18,6 +18,7 @@ import {
 } from '../../interfaces/search';
 import { PageModel } from '../../models/page';
 import { createBatchStream } from '../../util/batch-stream';
+import { UpdateOrInsertPagesOpts } from '../interfaces/search';
 
 
 import ElasticsearchClient from './elasticsearch-client';
@@ -437,7 +438,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
 
   addAllPages() {
     const Page = mongoose.model('Page');
-    return this.updateOrInsertPages(() => Page.find(), { isEmittingProgressEvent: true, invokeGarbageCollection: true });
+    return this.updateOrInsertPages(() => Page.find(), { shouldEmitProgress: true, invokeGarbageCollection: true });
   }
 
   updateOrInsertPageById(pageId) {
@@ -456,8 +457,8 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
   /**
    * @param {function} queryFactory factory method to generate a Mongoose Query instance
    */
-  async updateOrInsertPages(queryFactory, option: any = {}) {
-    const { isEmittingProgressEvent = false, invokeGarbageCollection = false } = option;
+  async updateOrInsertPages(queryFactory, option: UpdateOrInsertPagesOpts = {}) {
+    const { shouldEmitProgress = false, invokeGarbageCollection = false } = option;
 
     const Page = mongoose.model('Page') as unknown as PageModel;
     const { PageQueryBuilder } = Page;
@@ -465,7 +466,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     const Comment = mongoose.model('Comment') as any; // TODO: typescriptize model
     const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: typescriptize model
 
-    const socket = this.socketIoService.getAdminSocket();
+    const socket = shouldEmitProgress ? this.socketIoService.getAdminSocket() : null;
 
     // prepare functions invoked from custom streams
     const prepareBodyForCreate = this.prepareBodyForCreate.bind(this);
@@ -583,8 +584,8 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
 
           logger.info(`Adding pages progressing: (count=${count}, errors=${res.errors}, took=${res.took}ms)`);
 
-          if (isEmittingProgressEvent) {
-            socket.emit('addPageProgress', { totalCount, count, skipped });
+          if (shouldEmitProgress) {
+            socket?.emit('addPageProgress', { totalCount, count, skipped });
           }
         }
         catch (err) {
@@ -607,8 +608,8 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
       final(callback) {
         logger.info(`Adding pages has completed: (totalCount=${totalCount}, skipped=${skipped})`);
 
-        if (isEmittingProgressEvent) {
-          socket.emit('finishAddPage', { totalCount, count, skipped });
+        if (shouldEmitProgress) {
+          socket?.emit('finishAddPage', { totalCount, count, skipped });
         }
         callback();
       },
@@ -623,7 +624,6 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
       .pipe(writeStream);
 
     return streamToPromise(writeStream);
-
   }
 
   deletePages(pages) {

+ 0 - 1
packages/app/src/server/views/layout-growi/not_found.html

@@ -10,7 +10,6 @@
   </div>
   <div
     id="growi-not-found-context"
-    data-is-not-found-permalink="{% if isNotFoundPermalink %}{{isNotFoundPermalink|json}}{% endif %}"
     data-page-id="{%if pageId %}{{pageId.toString()}}{% endif %}"
   >
   </div>

+ 9 - 12
packages/app/src/stores/context.tsx

@@ -3,7 +3,7 @@ import { Key, SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
 
-import { TargetAndAncestors, IsNotFoundPermalink } from '../interfaces/page-listing-results';
+import { TargetAndAncestors } from '../interfaces/page-listing-results';
 import { IUser } from '../interfaces/user';
 
 import { useStaticSWR } from './use-static-swr';
@@ -88,6 +88,10 @@ export const useIsForbidden = (initialData?: boolean): SWRResponse<boolean, Erro
   return useStaticSWR<boolean, Error>('isForbidden', initialData, { fallbackData: false });
 };
 
+export const useIsNotFound = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useStaticSWR<boolean, Error>('isNotFound', initialData, { fallbackData: false });
+};
+
 export const usePageUser = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
   return useStaticSWR<Nullable<any>, Error>('pageUser', initialData);
 };
@@ -148,14 +152,6 @@ export const useTargetAndAncestors = (initialData?: TargetAndAncestors): SWRResp
   return useStaticSWR<TargetAndAncestors, Error>('targetAndAncestors', initialData);
 };
 
-export const useNotFoundTargetPathOrId = (initialData?: string): SWRResponse<string, Error> => {
-  return useStaticSWR<string, Error>('notFoundTargetPathOrId', initialData);
-};
-
-export const useIsNotFoundPermalink = (initialData?: Nullable<IsNotFoundPermalink>): SWRResponse<Nullable<IsNotFoundPermalink>, Error> => {
-  return useStaticSWR<Nullable<IsNotFoundPermalink>, Error>('isNotFoundPermalink', initialData);
-};
-
 export const useIsAclEnabled = (initialData?: boolean) : SWRResponse<boolean, Error> => {
   return useStaticSWR<boolean, Error>('isAclEnabled', initialData);
 };
@@ -172,13 +168,14 @@ export const useIsSearchScopeChildrenAsDefault = (initialData?: boolean) : SWRRe
   return useStaticSWR<boolean, Error>('isSearchScopeChildrenAsDefault', initialData);
 };
 
+export const useIsSlackConfigured = (initialData?: boolean) : SWRResponse<boolean, Error> => {
+  return useStaticSWR<boolean, Error>('isSlackConfigured', initialData);
+};
+
 export const useIsEnabledAttachTitleHeader = (initialData?: boolean) : SWRResponse<boolean, Error> => {
   return useStaticSWR<boolean, Error>('isEnabledAttachTitleHeader', initialData);
 };
 
-export const useIsEmptyPage = (initialData?: boolean) : SWRResponse<boolean, Error> => {
-  return useStaticSWR<boolean, Error>('isEmptyPage', initialData);
-};
 export const useHasParent = (initialData?: boolean) : SWRResponse<boolean, Error> => {
   return useStaticSWR<boolean, Error>('hasParent', initialData);
 };

+ 4 - 0
packages/app/src/stores/editor.tsx

@@ -108,3 +108,7 @@ export const usePageTagsForEditors = (pageId: Nullable<string>): SWRResponse<str
     },
   };
 };
+
+export const useIsEnabledUnsavedWarning = (): SWRResponse<boolean, Error> => {
+  return useStaticSWR<boolean, Error>('isEnabledUnsavedWarning', undefined, { fallbackData: false });
+};

+ 10 - 10
packages/app/src/stores/ui.tsx

@@ -21,7 +21,7 @@ import loggerFactory from '~/utils/logger';
 
 import {
   useCurrentPageId, useCurrentPagePath, useIsEditable, useIsTrashPage, useIsUserPage, useIsGuestUser, useEmptyPageId,
-  useIsNotCreatable, useIsSharedUser, useNotFoundTargetPathOrId, useIsForbidden, useIsIdenticalPath, useIsNotFoundPermalink, useCurrentUser,
+  useIsNotCreatable, useIsSharedUser, useIsForbidden, useIsIdenticalPath, useCurrentUser, useIsNotFound,
 } from './context';
 import { localStorageMiddleware } from './middlewares/sync-to-storage';
 import { useStaticSWR } from './use-static-swr';
@@ -113,7 +113,10 @@ const updateHashByEditorMode = (newEditorMode: EditorMode) => {
 };
 
 export const determineEditorModeByHash = (): EditorMode => {
-  if (isServer()) return EditorMode.View;
+  if (isServer()) {
+    return EditorMode.View;
+  }
+
   const { hash } = window.location;
 
   switch (hash) {
@@ -425,18 +428,16 @@ export const useIsAbleToShowTagLabel = (): SWRResponse<boolean, Error> => {
   const { data: isUserPage } = useIsUserPage();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: isIdenticalPath } = useIsIdenticalPath();
-  const { data: notFoundTargetPathOrId } = useNotFoundTargetPathOrId();
+  const { data: isNotFound } = useIsNotFound();
   const { data: editorMode } = useEditorMode();
 
-  const includesUndefined = [isUserPage, isSharedUser, isIdenticalPath, notFoundTargetPathOrId, editorMode].some(v => v === undefined);
+  const includesUndefined = [isUserPage, isSharedUser, isIdenticalPath, isNotFound, editorMode].some(v => v === undefined);
 
   const isViewMode = editorMode === EditorMode.View;
-  const isNotFoundPage = notFoundTargetPathOrId != null;
 
   return useSWRImmutable(
     includesUndefined ? null : [key, editorMode],
-    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-    () => !isUserPage && !isSharedUser && !isIdenticalPath && !(isViewMode && isNotFoundPage),
+    () => !isUserPage && !isSharedUser && !isIdenticalPath && !(isViewMode && isNotFound),
   );
 };
 
@@ -446,13 +447,12 @@ export const useIsAbleToShowPageEditorModeManager = (): SWRResponse<boolean, Err
   const { data: isForbidden } = useIsForbidden();
   const { data: isTrashPage } = useIsTrashPage();
   const { data: isSharedUser } = useIsSharedUser();
-  const { data: isNotFoundPermalink } = useIsNotFoundPermalink();
 
-  const includesUndefined = [isNotCreatable, isForbidden, isTrashPage, isSharedUser, isNotFoundPermalink].some(v => v === undefined);
+  const includesUndefined = [isNotCreatable, isForbidden, isTrashPage, isSharedUser].some(v => v === undefined);
 
   return useSWRImmutable(
     includesUndefined ? null : key,
-    () => !isNotCreatable && !isForbidden && !isTrashPage && !isSharedUser && !isNotFoundPermalink,
+    () => !isNotCreatable && !isForbidden && !isTrashPage && !isSharedUser,
   );
 };
 

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

@@ -18,60 +18,157 @@ describe('loginRequired', () => {
   });
 
   describe('not strict mode', () => {
-    // setup req/res/next
-    const req = {
-      originalUrl: 'original url 1',
-      session: {},
-    };
     const res = {
       redirect: jest.fn().mockReturnValue('redirect'),
+      sendStatus: jest.fn().mockReturnValue('sendStatus'),
     };
     const next = jest.fn().mockReturnValue('next');
 
-    test('pass guest user when aclService.isGuestAllowedToRead() returns true', () => {
-      // prepare spy for AclService.isGuestAllowedToRead
-      const isGuestAllowedToReadSpy = jest.spyOn(crowi.aclService, 'isGuestAllowedToRead')
-        .mockImplementation(() => true);
-
-      const result = loginRequired(req, res, next);
-
-      expect(isGuestAllowedToReadSpy).toHaveBeenCalledTimes(1);
-      expect(fallbackMock).not.toHaveBeenCalled();
-      expect(next).toHaveBeenCalled();
-      expect(res.redirect).not.toHaveBeenCalled();
-      expect(result).toBe('next');
-    });
-
-    test('redirect to \'/login\' when aclService.isGuestAllowedToRead() returns false', () => {
-      // prepare spy for AclService.isGuestAllowedToRead
-      const isGuestAllowedToReadSpy = jest.spyOn(crowi.aclService, 'isGuestAllowedToRead')
-        .mockImplementation(() => false);
+    describe('and when aclService.isGuestAllowedToRead() returns false', () => {
+      let req;
+
+      let isGuestAllowedToReadSpy;
+
+      beforeEach(async() => {
+        // setup req
+        req = {
+          originalUrl: 'original url 1',
+          session: {},
+        };
+        // reset session object
+        req.session = {};
+        // prepare spy for AclService.isGuestAllowedToRead
+        isGuestAllowedToReadSpy = jest.spyOn(crowi.aclService, 'isGuestAllowedToRead')
+          .mockImplementation(() => false);
+      });
+
+      /* eslint-disable indent */
+      test.each`
+        userStatus  | expectedPath
+        ${1}        | ${'/login/error/registered'}
+        ${3}        | ${'/login/error/suspended'}
+        ${5}        | ${'/login/invited'}
+      `('redirect to \'$expectedPath\' when user.status is \'$userStatus\'', ({ userStatus, expectedPath }) => {
+
+        req.user = {
+          _id: 'user id',
+          status: userStatus,
+        };
+
+        const result = loginRequired(req, res, next);
+
+        expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
+        expect(next).not.toHaveBeenCalled();
+        expect(fallbackMock).not.toHaveBeenCalled();
+        expect(res.sendStatus).not.toHaveBeenCalled();
+        expect(res.redirect).toHaveBeenCalledTimes(1);
+        expect(res.redirect).toHaveBeenCalledWith(expectedPath);
+        expect(result).toBe('redirect');
+        expect(req.session.redirectTo).toBe(undefined);
+      });
+      /* eslint-disable indent */
+
+      test('redirect to \'/login\' when the user does not loggedin', () => {
+        req.baseUrl = '/path/that/requires/loggedin';
+
+        const result = loginRequired(req, res, next);
+
+        expect(isGuestAllowedToReadSpy).toHaveBeenCalled();
+        expect(next).not.toHaveBeenCalled();
+        expect(fallbackMock).not.toHaveBeenCalled();
+        expect(res.sendStatus).not.toHaveBeenCalled();
+        expect(res.redirect).toHaveBeenCalledTimes(1);
+        expect(res.redirect).toHaveBeenCalledWith('/login');
+        expect(result).toBe('redirect');
+        expect(req.session.redirectTo).toBe('original url 1');
+      });
+
+      test('pass anyone into sharedPage', () => {
+
+        req.isSharedPage = true;
+
+        const result = loginRequired(req, res, next);
+
+        expect(isGuestAllowedToReadSpy).toHaveBeenCalled();
+        expect(fallbackMock).not.toHaveBeenCalled();
+        expect(res.sendStatus).not.toHaveBeenCalled();
+        expect(next).toHaveBeenCalled();
+        expect(res.redirect).not.toHaveBeenCalled();
+        expect(result).toBe('next');
+      });
 
-      const result = loginRequired(req, res, next);
-
-      expect(isGuestAllowedToReadSpy).toHaveBeenCalled();
-      expect(fallbackMock).not.toHaveBeenCalled();
-      expect(next).not.toHaveBeenCalled();
-      expect(res.redirect).toHaveBeenCalledTimes(1);
-      expect(res.redirect).toHaveBeenCalledWith('/login');
-      expect(result).toBe('redirect');
     });
 
-    test('pass anyone into sharedPage when aclService.isGuestAllowedToRead() returns false', () => {
+    describe('and when aclService.isGuestAllowedToRead() returns true', () => {
+      let req;
+
+      let isGuestAllowedToReadSpy;
+
+      beforeEach(async() => {
+        // setup req
+        req = {
+          originalUrl: 'original url 1',
+          session: {},
+        };
+        // reset session object
+        req.session = {};
+        // prepare spy for AclService.isGuestAllowedToRead
+        isGuestAllowedToReadSpy = jest.spyOn(crowi.aclService, 'isGuestAllowedToRead')
+          .mockImplementation(() => true);
+      });
+
+      /* eslint-disable indent */
+      test.each`
+        userStatus  | expectedPath
+        ${1}        | ${'/login/error/registered'}
+        ${3}        | ${'/login/error/suspended'}
+        ${5}        | ${'/login/invited'}
+      `('redirect to \'$expectedPath\' when user.status is \'$userStatus\'', ({ userStatus, expectedPath }) => {
+
+        req.user = {
+          _id: 'user id',
+          status: userStatus,
+        };
+
+        const result = loginRequired(req, res, next);
+
+        expect(isGuestAllowedToReadSpy).not.toHaveBeenCalled();
+        expect(next).not.toHaveBeenCalled();
+        expect(fallbackMock).not.toHaveBeenCalled();
+        expect(res.sendStatus).not.toHaveBeenCalled();
+        expect(res.redirect).toHaveBeenCalledTimes(1);
+        expect(res.redirect).toHaveBeenCalledWith(expectedPath);
+        expect(result).toBe('redirect');
+        expect(req.session.redirectTo).toBe(undefined);
+      });
+      /* eslint-disable indent */
+
+      test('pass guest user', () => {
+
+        const result = loginRequired(req, res, next);
+
+        expect(isGuestAllowedToReadSpy).toHaveBeenCalledTimes(1);
+        expect(fallbackMock).not.toHaveBeenCalled();
+        expect(res.sendStatus).not.toHaveBeenCalled();
+        expect(next).toHaveBeenCalled();
+        expect(res.redirect).not.toHaveBeenCalled();
+        expect(result).toBe('next');
+      });
+
+      test('pass anyone into sharedPage', () => {
+
+        req.isSharedPage = true;
+
+        const result = loginRequired(req, res, next);
+
+        expect(isGuestAllowedToReadSpy).toHaveBeenCalled();
+        expect(fallbackMock).not.toHaveBeenCalled();
+        expect(res.sendStatus).not.toHaveBeenCalled();
+        expect(next).toHaveBeenCalled();
+        expect(res.redirect).not.toHaveBeenCalled();
+        expect(result).toBe('next');
+      });
 
-      req.isSharedPage = true;
-
-      // prepare spy for AclService.isGuestAllowedToRead
-      const isGuestAllowedToReadSpy = jest.spyOn(crowi.aclService, 'isGuestAllowedToRead')
-        .mockImplementation(() => false);
-
-      const result = loginRequired(req, res, next);
-
-      expect(isGuestAllowedToReadSpy).toHaveBeenCalled();
-      expect(fallbackMock).not.toHaveBeenCalled();
-      expect(next).toHaveBeenCalled();
-      expect(res.redirect).not.toHaveBeenCalled();
-      expect(result).toBe('next');
     });
 
   });

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

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

+ 1 - 1
packages/core/package.json

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

+ 11 - 0
packages/core/src/utils/page-path-utils.ts

@@ -1,6 +1,8 @@
 import nodePath from 'path';
 
 import escapeStringRegexp from 'escape-string-regexp';
+import { isValidObjectId } from 'mongoose';
+
 
 import { addTrailingSlash } from './path-utils';
 
@@ -20,6 +22,15 @@ export const isUsersTopPage = (path: string): boolean => {
   return path === '/user';
 };
 
+/**
+ * Whether the path is permalink
+ * @param path
+ */
+export const isPermalink = (path: string): boolean => {
+  const pageIdStr = path.substring(1);
+  return isValidObjectId(pageIdStr);
+};
+
 /**
  * Whether path is user's home page
  * @param path

+ 16 - 0
packages/core/src/utils/path-utils.js

@@ -73,6 +73,22 @@ export function addTrailingSlash(path) {
   return path;
 }
 
+/**
+ *
+ * @param {string} path
+ * @returns {string}
+ * @memberof pathUtils
+ */
+export function removeHeadingSlash(path) {
+  if (path === '/') {
+    return path;
+  }
+
+  return hasHeadingSlash(path)
+    ? path.substring(1)
+    : path;
+}
+
 /**
  *
  * @param {string} path

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

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

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

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-lsx",
-  "version": "5.0.11-RC.0",
+  "version": "5.0.12-RC.0",
   "description": "GROWI plugin to list pages",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-pukiwiki-like-linker/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-pukiwiki-like-linker",
-  "version": "5.0.11-RC.0",
+  "version": "5.0.12-RC.0",
   "description": "GROWI plugin to add PukiwikiLikeLinker",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slack",
-  "version": "5.0.11-RC.0",
+  "version": "5.0.12-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.0.11-slackbot-proxy.0",
+  "version": "5.0.12-slackbot-proxy.0",
   "license": "MIT",
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
@@ -25,7 +25,7 @@
   },
   "dependencies": {
     "@godaddy/terminus": "^4.9.0",
-    "@growi/slack": "^5.0.11-RC.0",
+    "@growi/slack": "^5.0.12-RC.0",
     "@slack/oauth": "^2.0.1",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "^6.43.0",

+ 1 - 1
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/ui",
-  "version": "5.0.11-RC.0",
+  "version": "5.0.12-RC.0",
   "description": "GROWI UI Libraries",
   "license": "MIT",
   "keywords": [