Jelajahi Sumber

Merge branch 'master' into support/cherry-pick-6910

Haku Mizuki 3 tahun lalu
induk
melakukan
e625f3368e

+ 15 - 1
packages/app/public/static/locales/en_US/commons.json

@@ -3,6 +3,7 @@
   "Hide": "Hide",
   "Hide": "Hide",
   "Add": "Add",
   "Add": "Add",
   "Reset": "Reset",
   "Reset": "Reset",
+  "Sign out": "Logout",
 
 
   "meta": {
   "meta": {
     "display_name": "English"
     "display_name": "English"
@@ -42,9 +43,22 @@
     "description": "Description"
     "description": "Description"
   },
   },
 
 
+  "in_app_notification": {
+    "notification_list": "In-App Notification List",
+    "see_all": "See All",
+    "no_notification": "You don't have any notificatios.",
+    "all": "All",
+    "unopend": "Unread",
+    "mark_all_as_read": "Mark all as read"
+  },
+
   "personal_dropdown": {
   "personal_dropdown": {
     "home": "Home",
     "home": "Home",
-    "settings": "Settings"
+    "settings": "Settings",
+    "color_mode": "Color mode",
+    "sidebar_mode": "Sidebar mode",
+    "sidebar_mode_editor": "Sidebar mode on editor",
+    "use_os_settings": "Use OS settings"
   },
   },
 
 
   "copy_to_clipboard": {
   "copy_to_clipboard": {

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

@@ -138,7 +138,6 @@
   "edited this page": "edited this page.",
   "edited this page": "edited this page.",
   "List Drafts": "Drafts",
   "List Drafts": "Drafts",
   "Deleted Pages": "Deleted Pages",
   "Deleted Pages": "Deleted Pages",
-  "Sign out": "Logout",
   "Disassociate": "Disassociate",
   "Disassociate": "Disassociate",
   "No bookmarks yet": "No bookmarks yet",
   "No bookmarks yet": "No bookmarks yet",
   "add_bookmark": "Add to Bookmarks",
   "add_bookmark": "Add to Bookmarks",
@@ -155,12 +154,6 @@
   "not_allowed_to_see_this_page": "You cannot see this page",
   "not_allowed_to_see_this_page": "You cannot see this page",
   "Confirm": "Confirm",
   "Confirm": "Confirm",
   "Successfully requested": "Successfully requested.",
   "Successfully requested": "Successfully requested.",
-  "personal_dropdown": {
-    "color_mode": "Color mode",
-    "sidebar_mode": "Sidebar mode",
-    "sidebar_mode_editor": "Sidebar mode on editor",
-    "use_os_settings": "Use OS settings"
-  },
   "form_validation": {
   "form_validation": {
     "error_message": "Some values ​​are incorrect",
     "error_message": "Some values ​​are incorrect",
     "required": "%s is required",
     "required": "%s is required",
@@ -243,14 +236,6 @@
   "API Token Settings": "API token settings",
   "API Token Settings": "API token settings",
   "Current API Token": "Current API token",
   "Current API Token": "Current API token",
   "Update API Token": "Update API token",
   "Update API Token": "Update API token",
-  "in_app_notification": {
-    "notification_list": "In-App Notification List",
-    "see_all": "See All",
-    "no_notification": "You don't have any notificatios.",
-    "all": "All",
-    "unopend": "Unread",
-    "mark_all_as_read": "Mark all as read"
-  },
   "in_app_notification_settings": {
   "in_app_notification_settings": {
     "in_app_notification_settings": "In-App Notification Settings",
     "in_app_notification_settings": "In-App Notification Settings",
     "subscribe_settings": "Settings to automatically subscribe (Receive notifications) to pages",
     "subscribe_settings": "Settings to automatically subscribe (Receive notifications) to pages",

+ 15 - 1
packages/app/public/static/locales/ja_JP/commons.json

@@ -3,6 +3,7 @@
   "Hide": "非公開",
   "Hide": "非公開",
   "Add": "追加",
   "Add": "追加",
   "Reset": "リセット",
   "Reset": "リセット",
+  "Sign out": "ログアウト",
 
 
   "meta": {
   "meta": {
     "display_name": "日本語"
     "display_name": "日本語"
@@ -42,9 +43,22 @@
     "description": "概要"
     "description": "概要"
   },
   },
 
 
+  "in_app_notification": {
+    "notification_list": "アプリ内通知一覧",
+    "see_all": "通知一覧を見る",
+    "no_notification": "通知はありません",
+    "all": "全て",
+    "unopend": "未読",
+    "mark_all_as_read": "全て既読にする"
+  },
+
   "personal_dropdown": {
   "personal_dropdown": {
     "home": "ホーム",
     "home": "ホーム",
-    "settings": "設定"
+    "settings": "設定",
+    "color_mode": "カラーモード",
+    "sidebar_mode": "サイドバーモード",
+    "sidebar_mode_editor": "サイドバーモード(編集時)",
+    "use_os_settings": "OS設定を利用する"
   },
   },
 
 
   "copy_to_clipboard": {
   "copy_to_clipboard": {

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

@@ -133,7 +133,6 @@
   "edited this page": "さんがこのページを編集しました。",
   "edited this page": "さんがこのページを編集しました。",
   "List Drafts": "下書き一覧",
   "List Drafts": "下書き一覧",
   "Deleted Pages": "削除済みページ",
   "Deleted Pages": "削除済みページ",
-  "Sign out": "ログアウト",
   "Disassociate": "連携解除",
   "Disassociate": "連携解除",
   "Color mode": "カラーモード",
   "Color mode": "カラーモード",
   "Sidebar mode": "サイドバーモード",
   "Sidebar mode": "サイドバーモード",
@@ -153,12 +152,6 @@
   "not_allowed_to_see_this_page": "このページは閲覧できません",
   "not_allowed_to_see_this_page": "このページは閲覧できません",
   "Confirm": "確認",
   "Confirm": "確認",
   "Successfully requested": "正常に処理を受け付けました",
   "Successfully requested": "正常に処理を受け付けました",
-  "personal_dropdown": {
-    "color_mode": "カラーモード",
-    "sidebar_mode": "サイドバーモード",
-    "sidebar_mode_editor": "サイドバーモード(編集時)",
-    "use_os_settings": "OS設定を利用する"
-  },
   "form_validation": {
   "form_validation": {
     "error_message": "いくつかの値が設定されていません",
     "error_message": "いくつかの値が設定されていません",
     "required": "%sに値を入力してください",
     "required": "%sに値を入力してください",
@@ -241,14 +234,6 @@
   "API Token Settings": "API Token設定",
   "API Token Settings": "API Token設定",
   "Current API Token": "現在のAPI Token",
   "Current API Token": "現在のAPI Token",
   "Update API Token": "API Tokenを更新",
   "Update API Token": "API Tokenを更新",
-  "in_app_notification": {
-    "notification_list": "アプリ内通知一覧",
-    "see_all": "通知一覧を見る",
-    "no_notification": "通知はありません",
-    "all": "全て",
-    "unopend": "未読",
-    "mark_all_as_read": "全て既読にする"
-  },
   "in_app_notification_settings": {
   "in_app_notification_settings": {
     "in_app_notification_settings": "アプリ内通知設定",
     "in_app_notification_settings": "アプリ内通知設定",
     "subscribe_settings": "自動でページをサブスクライブする(通知を受け取る)設定",
     "subscribe_settings": "自動でページをサブスクライブする(通知を受け取る)設定",

+ 15 - 1
packages/app/public/static/locales/zh_CN/commons.json

@@ -3,6 +3,7 @@
 	"Hide": "隐藏",
 	"Hide": "隐藏",
   "Add": "添加",
   "Add": "添加",
   "Reset": "重启",
   "Reset": "重启",
+	"Sign out": "退出",
 
 
   "meta": {
   "meta": {
     "display_name": "简体中文"
     "display_name": "简体中文"
@@ -42,9 +43,22 @@
     "description": "Description"
     "description": "Description"
   },
   },
 
 
+  "in_app_notification": {
+    "notification_list": "应用内通知列表",
+    "see_all": "查看通知列表",
+    "no_notification": "您没有任何通知",
+    "all": "全部",
+    "unopend": "未读",
+    "mark_all_as_read" : "标记为已读"
+  },
+
   "personal_dropdown": {
   "personal_dropdown": {
     "home": "家",
     "home": "家",
-    "settings": "设置"
+    "settings": "设置",
+		"color_mode": "颜色模式",
+		"sidebar_mode": "边栏模式",
+		"sidebar_mode_editor": "编辑器上的边栏模式",
+		"use_os_settings": "使用操作系统设置"
   },
   },
 
 
 	"copy_to_clipboard": {
 	"copy_to_clipboard": {

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

@@ -145,7 +145,6 @@
 	"edited this page": "edited this page.",
 	"edited this page": "edited this page.",
 	"List Drafts": "草稿",
 	"List Drafts": "草稿",
 	"Deleted Pages": "已删除页",
 	"Deleted Pages": "已删除页",
-	"Sign out": "退出",
   "Disassociate": "解除关联",
   "Disassociate": "解除关联",
   "No bookmarks yet": "暂无书签",
   "No bookmarks yet": "暂无书签",
   "add_bookmark": "添加到书签",
   "add_bookmark": "添加到书签",
@@ -228,14 +227,6 @@
 	"API Token Settings": "API token 设置",
 	"API Token Settings": "API token 设置",
 	"Current API Token": "当前 API token",
 	"Current API Token": "当前 API token",
 	"Update API Token": "更新 API token",
 	"Update API Token": "更新 API token",
-  "in_app_notification": {
-    "notification_list": "应用内通知列表",
-    "see_all": "查看通知列表",
-    "no_notification": "您没有任何通知",
-    "all": "全部",
-    "unopend": "未读",
-    "mark_all_as_read" : "标记为已读"
-  },
   "in_app_notification_settings": {
   "in_app_notification_settings": {
     "in_app_notification_settings": "在应用程序通知设置",
     "in_app_notification_settings": "在应用程序通知设置",
     "subscribe_settings": "自动订阅(接收通知)页面的设置",
     "subscribe_settings": "自动订阅(接收通知)页面的设置",
@@ -566,12 +557,6 @@
     "Invalid_Number_of_Date" : "You entered invalid value",
     "Invalid_Number_of_Date" : "You entered invalid value",
     "link_sharing_is_disabled": "链接共享已被禁用"
     "link_sharing_is_disabled": "链接共享已被禁用"
   },
   },
-	"personal_dropdown": {
-		"color_mode": "颜色模式",
-		"sidebar_mode": "边栏模式",
-		"sidebar_mode_editor": "编辑器上的边栏模式",
-		"use_os_settings": "使用操作系统设置"
-	},
 	"search_result": {
 	"search_result": {
 		"result_meta": "搜索结果:",
 		"result_meta": "搜索结果:",
 		"deletion_mode_btn_lavel": "选择并删除页面",
 		"deletion_mode_btn_lavel": "选择并删除页面",

+ 1 - 1
packages/app/src/components/InAppNotification/InAppNotificationDropdown.tsx

@@ -20,7 +20,7 @@ const logger = loggerFactory('growi:InAppNotificationDropdown');
 
 
 
 
 export const InAppNotificationDropdown = (): JSX.Element => {
 export const InAppNotificationDropdown = (): JSX.Element => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('commons');
 
 
   const [isOpen, setIsOpen] = useState(false);
   const [isOpen, setIsOpen] = useState(false);
   const limit = 6;
   const limit = 6;

+ 1 - 1
packages/app/src/components/InAppNotification/InAppNotificationElm.tsx

@@ -97,7 +97,7 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
       break;
       break;
     case 'PAGE_UPDATE':
     case 'PAGE_UPDATE':
       actionMsg = 'updated on';
       actionMsg = 'updated on';
-      actionIcon = 'ti-agenda';
+      actionIcon = 'ti ti-agenda';
       break;
       break;
     case 'PAGE_RENAME':
     case 'PAGE_RENAME':
       actionMsg = 'renamed';
       actionMsg = 'renamed';

+ 1 - 1
packages/app/src/components/InAppNotification/InAppNotificationPage.tsx

@@ -20,7 +20,7 @@ const logger = loggerFactory('growi:InAppNotificationPage');
 
 
 
 
 export const InAppNotificationPage: FC = () => {
 export const InAppNotificationPage: FC = () => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('commons');
   const { mutate } = useSWRxInAppNotificationStatus();
   const { mutate } = useSWRxInAppNotificationStatus();
 
 
   const { data: showPageLimitationXL } = useShowPageLimitationXL();
   const { data: showPageLimitationXL } = useShowPageLimitationXL();

+ 1 - 1
packages/app/src/components/Navbar/AppearanceModeDropdown.tsx

@@ -20,7 +20,7 @@ type AppearanceModeDropdownProps = {
 }
 }
 export const AppearanceModeDropdown:FC<AppearanceModeDropdownProps> = (props: AppearanceModeDropdownProps) => {
 export const AppearanceModeDropdown:FC<AppearanceModeDropdownProps> = (props: AppearanceModeDropdownProps) => {
 
 
-  const { t } = useTranslation();
+  const { t } = useTranslation('commons');
 
 
   const { isAuthenticated } = props;
   const { isAuthenticated } = props;
 
 

+ 1 - 2
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -15,8 +15,7 @@ import {
 import { IResTagsUpdateApiv1 } from '~/interfaces/tag';
 import { IResTagsUpdateApiv1 } from '~/interfaces/tag';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import {
 import {
-  useCurrentPageId, useCurrentPathname,
-  useIsNotFound,
+  useCurrentPageId, useCurrentPathname, useIsNotFound,
   useCurrentUser, useIsGuestUser, useIsSharedUser, useShareLinkId, useTemplateTagData, useIsContainerFluid,
   useCurrentUser, useIsGuestUser, useIsSharedUser, useShareLinkId, useTemplateTagData, useIsContainerFluid,
 } from '~/stores/context';
 } from '~/stores/context';
 import { usePageTagsForEditors } from '~/stores/editor';
 import { usePageTagsForEditors } from '~/stores/editor';

+ 34 - 13
packages/app/src/components/PageEditor.tsx

@@ -4,9 +4,10 @@ import React, {
 
 
 import EventEmitter from 'events';
 import EventEmitter from 'events';
 
 
-import { envUtils, PageGrant } from '@growi/core';
+import { envUtils, IPageHasId, PageGrant } from '@growi/core';
 import detectIndent from 'detect-indent';
 import detectIndent from 'detect-indent';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import { useRouter } from 'next/router';
 import { throttle, debounce } from 'throttle-debounce';
 import { throttle, debounce } from 'throttle-debounce';
 
 
 import { saveOrUpdate } from '~/client/services/page-operation';
 import { saveOrUpdate } from '~/client/services/page-operation';
@@ -16,7 +17,7 @@ import { getOptionsToSave } from '~/client/util/editor';
 import { IEditorMethods } from '~/interfaces/editor-methods';
 import { IEditorMethods } from '~/interfaces/editor-methods';
 import {
 import {
   useCurrentPathname, useCurrentPageId,
   useCurrentPathname, useCurrentPageId,
-  useIsEditable, useIsIndentSizeForced, useIsUploadableFile, useIsUploadableImage, useEditingMarkdown,
+  useIsEditable, useIsIndentSizeForced, useIsUploadableFile, useIsUploadableImage, useEditingMarkdown, useIsNotFound,
 } from '~/stores/context';
 } from '~/stores/context';
 import {
 import {
   useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
   useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
@@ -51,7 +52,10 @@ let isOriginOfScrollSyncPreview = false;
 const PageEditor = React.memo((): JSX.Element => {
 const PageEditor = React.memo((): JSX.Element => {
 
 
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const { data: pageId } = useCurrentPageId();
+  const router = useRouter();
+
+  const { data: isNotFound } = useIsNotFound();
+  const { data: pageId, mutate: mutateCurrentPageId } = useCurrentPageId();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
   const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
@@ -66,7 +70,7 @@ const PageEditor = React.memo((): JSX.Element => {
   const { data: isTextlintEnabled } = useIsTextlintEnabled();
   const { data: isTextlintEnabled } = useIsTextlintEnabled();
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
   const { data: indentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
   const { data: indentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
-  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
+  const { data: isEnabledUnsavedWarning, mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const { data: isUploadableFile } = useIsUploadableFile();
   const { data: isUploadableFile } = useIsUploadableFile();
   const { data: isUploadableImage } = useIsUploadableImage();
   const { data: isUploadableImage } = useIsUploadableImage();
 
 
@@ -112,7 +116,7 @@ const PageEditor = React.memo((): JSX.Element => {
   }, [setMarkdownWithDebounce]);
   }, [setMarkdownWithDebounce]);
 
 
   // return true if the save succeeds, otherwise false.
   // return true if the save succeeds, otherwise false.
-  const save = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}): Promise<boolean> => {
+  const save = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}): Promise<IPageHasId | null> => {
     if (grantData == null || isSlackEnabled == null || currentPathname == null) {
     if (grantData == null || isSlackEnabled == null || currentPathname == null) {
       logger.error('Some materials to save are invalid', { grantData, isSlackEnabled, currentPathname });
       logger.error('Some materials to save are invalid', { grantData, isSlackEnabled, currentPathname });
       throw new Error('Some materials to save are invalid');
       throw new Error('Some materials to save are invalid');
@@ -127,10 +131,13 @@ const PageEditor = React.memo((): JSX.Element => {
     );
     );
 
 
     try {
     try {
-      await saveOrUpdate(optionsToSave, { pageId, path: currentPagePath || currentPathname, revisionId: currentRevisionId }, markdownToSave.current);
-      await mutateCurrentPage();
-      mutateIsEnabledUnsavedWarning(false);
-      return true;
+      const { page } = await saveOrUpdate(
+        optionsToSave,
+        { pageId, path: currentPagePath || currentPathname, revisionId: currentRevisionId },
+        markdownToSave.current,
+      );
+
+      return page;
     }
     }
     catch (error) {
     catch (error) {
       logger.error('failed to save', error);
       logger.error('failed to save', error);
@@ -143,20 +150,34 @@ const PageEditor = React.memo((): JSX.Element => {
         //   lastUpdateUser: error.data.user,
         //   lastUpdateUser: error.data.user,
         // });
         // });
       }
       }
-      return false;
+      return null;
     }
     }
 
 
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
-  }, [grantData, isSlackEnabled, currentPathname, slackChannels, pageTags, pageId, currentPagePath, currentRevisionId, mutateCurrentPage, mutateIsEnabledUnsavedWarning]);
+  }, [grantData, isSlackEnabled, currentPathname, slackChannels, pageTags, pageId, currentPagePath, currentRevisionId]);
 
 
   const saveAndReturnToViewHandler = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}) => {
   const saveAndReturnToViewHandler = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}) => {
     if (editorMode !== EditorMode.Editor) {
     if (editorMode !== EditorMode.Editor) {
       return;
       return;
     }
     }
 
 
-    await save(opts);
+    const page = await save(opts);
+    if (page == null) {
+      return;
+    }
+    // The updateFn should be a promise or asynchronous function to handle the remote mutation
+    // it should return updated data. see: https://swr.vercel.app/docs/mutation#optimistic-updates
+    // Moreover, `async() => false` does not work since it's too fast to be calculated.
+    await mutateIsEnabledUnsavedWarning(new Promise(r => setTimeout(() => r(false), 10)), { optimisticData: () => false });
+    if (isNotFound) {
+      await router.push(`/${page._id}`);
+    }
+    else {
+      await mutateCurrentPageId(page._id);
+      await mutateCurrentPage();
+    }
     mutateEditorMode(EditorMode.View);
     mutateEditorMode(EditorMode.View);
-  }, [editorMode, save, mutateEditorMode]);
+  }, [editorMode, save, mutateIsEnabledUnsavedWarning, isNotFound, mutateEditorMode, router, mutateCurrentPageId, mutateCurrentPage]);
 
 
   const saveWithShortcut = useCallback(async() => {
   const saveWithShortcut = useCallback(async() => {
     if (editorMode !== EditorMode.Editor) {
     if (editorMode !== EditorMode.Editor) {

+ 24 - 18
packages/app/src/components/PageEditorByHackmd.tsx

@@ -13,7 +13,7 @@ import { apiPost } from '~/client/util/apiv1-client';
 import { getOptionsToSave } from '~/client/util/editor';
 import { getOptionsToSave } from '~/client/util/editor';
 import { IResHackmdIntegrated, IResHackmdDiscard } from '~/interfaces/hackmd';
 import { IResHackmdIntegrated, IResHackmdDiscard } from '~/interfaces/hackmd';
 import {
 import {
-  useCurrentPageId, useCurrentPathname, useHackmdUri,
+  useCurrentPageId, useCurrentPathname, useHackmdUri, useIsNotFound,
 } from '~/stores/context';
 } from '~/stores/context';
 import {
 import {
   useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
   useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
@@ -29,6 +29,7 @@ import {
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import HackmdEditor from './PageEditorByHackmd/HackmdEditor';
 import HackmdEditor from './PageEditorByHackmd/HackmdEditor';
+import { useRouter } from 'next/router';
 
 
 const logger = loggerFactory('growi:PageEditorByHackmd');
 const logger = loggerFactory('growi:PageEditorByHackmd');
 
 
@@ -41,12 +42,15 @@ type HackEditorRef = {
 export const PageEditorByHackmd = (): JSX.Element => {
 export const PageEditorByHackmd = (): JSX.Element => {
 
 
   const { t } = useTranslation();
   const { t } = useTranslation();
+  const router = useRouter();
+
+  const { data: isNotFound } = useIsNotFound();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPathname } = useCurrentPathname();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isSlackEnabled } = useIsSlackEnabled();
   const { data: isSlackEnabled } = useIsSlackEnabled();
-  const { data: pageId } = useCurrentPageId();
+  const { data: pageId, mutate: mutateCurrentPageId } = useCurrentPageId();
   const { data: pageTags } = usePageTagsForEditors(pageId);
   const { data: pageTags } = usePageTagsForEditors(pageId);
   const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
   const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
   const { data: grant } = useSelectedGrant();
   const { data: grant } = useSelectedGrant();
@@ -100,30 +104,32 @@ export const PageEditorByHackmd = (): JSX.Element => {
 
 
       const markdown = await hackmdEditorRef.current.getValue();
       const markdown = await hackmdEditorRef.current.getValue();
 
 
-      await saveOrUpdate(optionsToSave, { pageId, path: currentPagePath || currentPathname, revisionId: revision?._id }, markdown);
+      const { page } = await saveOrUpdate(optionsToSave, { pageId, path: currentPagePath || currentPathname, revisionId: revision?._id }, markdown);
       await mutatePageData();
       await mutatePageData();
       await mutateTagsInfo();
       await mutateTagsInfo();
+
+      if (page == null) {
+        return;
+      }
+      // The updateFn should be a promise or asynchronous function to handle the remote mutation
+      // it should return updated data. see: https://swr.vercel.app/docs/mutation#optimistic-updates
+      // Moreover, `async() => false` does not work since it's too fast to be calculated.
+      await mutateIsEnabledUnsavedWarning(new Promise(r => setTimeout(() => r(false), 10)), { optimisticData: () => false });
+      if (isNotFound) {
+        await router.push(`/${page._id}`);
+      }
+      else {
+        await mutateCurrentPageId(page._id);
+        await mutatePageData();
+      }
       mutateEditorMode(EditorMode.View);
       mutateEditorMode(EditorMode.View);
-      mutateIsEnabledUnsavedWarning(false);
     }
     }
     catch (error) {
     catch (error) {
       logger.error('failed to save', error);
       logger.error('failed to save', error);
       toastError(error.message);
       toastError(error.message);
     }
     }
-  }, [editorMode,
-      isSlackEnabled,
-      currentPathname,
-      slackChannels,
-      grant,
-      revision,
-      pageTags,
-      pageId,
-      currentPagePath,
-      mutatePageData,
-      mutateEditorMode,
-      mutateTagsInfo,
-      mutateIsEnabledUnsavedWarning,
-  ]);
+  }, [editorMode, isSlackEnabled, currentPathname, slackChannels, grant, revision, pageTags, pageId,
+      currentPagePath, mutatePageData, mutateTagsInfo, mutateIsEnabledUnsavedWarning, isNotFound, mutateEditorMode, router, mutateCurrentPageId]);
 
 
   // set handler to save and reload Page
   // set handler to save and reload Page
   useEffect(() => {
   useEffect(() => {

+ 6 - 11
packages/app/src/components/SavePageControls.tsx

@@ -1,5 +1,7 @@
 import React, { useCallback } from 'react';
 import React, { useCallback } from 'react';
 
 
+import EventEmitter from 'events';
+
 import { pagePathUtils, PageGrant } from '@growi/core';
 import { pagePathUtils, PageGrant } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import {
 import {
@@ -7,30 +9,23 @@ import {
   DropdownToggle, DropdownMenu, DropdownItem,
   DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-// import PageContainer from '~/client/services/PageContainer';
-import { CustomWindow } from '~/interfaces/global';
 import { IPageGrantData } from '~/interfaces/page';
 import { IPageGrantData } from '~/interfaces/page';
 import {
 import {
   useIsEditable, useCurrentPageId, useIsAclEnabled,
   useIsEditable, useCurrentPageId, useIsAclEnabled,
 } from '~/stores/context';
 } from '~/stores/context';
-import { useIsEnabledUnsavedWarning } from '~/stores/editor';
 import { useCurrentPagePath } from '~/stores/page';
 import { useCurrentPagePath } from '~/stores/page';
 import { useSelectedGrant } from '~/stores/ui';
 import { useSelectedGrant } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import GrantSelector from './SavePageControls/GrantSelector';
 import GrantSelector from './SavePageControls/GrantSelector';
 
 
-// import { withUnstatedContainers } from './UnstatedUtils';
+declare const globalEmitter: EventEmitter;
 
 
 const logger = loggerFactory('growi:SavePageControls');
 const logger = loggerFactory('growi:SavePageControls');
 
 
-type Props = {
-  // pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-}
-
 const { isTopPage } = pagePathUtils;
 const { isTopPage } = pagePathUtils;
 
 
-export const SavePageControls = (props: Props): JSX.Element | null => {
+export const SavePageControls = (): JSX.Element | null => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: isEditable } = useIsEditable();
   const { data: isEditable } = useIsEditable();
@@ -45,12 +40,12 @@ export const SavePageControls = (props: Props): JSX.Element | null => {
 
 
   const save = useCallback(async(): Promise<void> => {
   const save = useCallback(async(): Promise<void> => {
     // save
     // save
-    (window as CustomWindow).globalEmitter.emit('saveAndReturnToView');
+    globalEmitter.emit('saveAndReturnToView');
   }, []);
   }, []);
 
 
   const saveAndOverwriteScopesOfDescendants = useCallback(() => {
   const saveAndOverwriteScopesOfDescendants = useCallback(() => {
     // save
     // save
-    (window as CustomWindow).globalEmitter.emit('saveAndReturnToView', { overwriteScopesOfDescendants: true });
+    globalEmitter.emit('saveAndReturnToView', { overwriteScopesOfDescendants: true });
   }, []);
   }, []);
 
 
 
 

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

@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect } from 'react';
+import React, { useCallback, useEffect, memo } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
@@ -56,4 +56,4 @@ const UnsavedAlertDialog = (): JSX.Element => {
   return <></>;
   return <></>;
 };
 };
 
 
-export default UnsavedAlertDialog;
+export default memo(UnsavedAlertDialog);

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

@@ -53,7 +53,7 @@ const InAppNotificationPage = dynamic(
 
 
 const MePage: NextPage<Props> = (props: Props) => {
 const MePage: NextPage<Props> = (props: Props) => {
   const router = useRouter();
   const router = useRouter();
-  const { t } = useTranslation();
+  const { t } = useTranslation(['translation', 'commons']);
   const { path } = router.query;
   const { path } = router.query;
   const pagePathKeys: string[] = Array.isArray(path) ? path : ['personal-settings'];
   const pagePathKeys: string[] = Array.isArray(path) ? path : ['personal-settings'];
 
 
@@ -68,7 +68,7 @@ const MePage: NextPage<Props> = (props: Props) => {
       //   component: <MyDraftList />,
       //   component: <MyDraftList />,
       // },
       // },
       'all-in-app-notifications': {
       'all-in-app-notifications': {
-        title: t('in_app_notification.notification_list'),
+        title: t('commons:in_app_notification.notification_list'),
         component: <InAppNotificationPage />,
         component: <InAppNotificationPage />,
       },
       },
     };
     };

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

@@ -1,6 +1,6 @@
 import { IUser, pagePathUtils } from '@growi/core';
 import { IUser, pagePathUtils } from '@growi/core';
 import { HtmlElementNode } from 'rehype-toc';
 import { HtmlElementNode } from 'rehype-toc';
-import { Key, SWRResponse } from 'swr';
+import { Key, SWRResponse, useSWRConfig } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
 
 
 
 
@@ -57,7 +57,7 @@ export const useCurrentPathname = (initialData?: string): SWRResponse<string, Er
 };
 };
 
 
 export const useCurrentPageId = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
 export const useCurrentPageId = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
-  return useContextSWR<Nullable<string>, Error>('currentPageId', initialData);
+  return useStaticSWR<Nullable<string>, Error>('currentPageId', initialData);
 };
 };
 
 
 export const useIsIdenticalPath = (initialData?: boolean): SWRResponse<boolean, Error> => {
 export const useIsIdenticalPath = (initialData?: boolean): SWRResponse<boolean, Error> => {

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

@@ -1,5 +1,5 @@
 import { Nullable, withUtils, SWRResponseWithUtils } from '@growi/core';
 import { Nullable, withUtils, SWRResponseWithUtils } from '@growi/core';
-import useSWR, { SWRResponse } from 'swr';
+import useSWR, { MutatorOptions, SWRResponse, useSWRConfig } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
 
 
 import { apiGet } from '~/client/util/apiv1-client';
 import { apiGet } from '~/client/util/apiv1-client';
@@ -115,5 +115,5 @@ export const usePageTagsForEditors = (pageId: Nullable<string>): SWRResponse<str
 };
 };
 
 
 export const useIsEnabledUnsavedWarning = (): SWRResponse<boolean, Error> => {
 export const useIsEnabledUnsavedWarning = (): SWRResponse<boolean, Error> => {
-  return useStaticSWR<boolean, Error>('isEnabledUnsavedWarning', undefined, { fallbackData: false });
+  return useStaticSWR<boolean, Error>('isEnabledUnsavedWarning');
 };
 };

+ 11 - 3
packages/app/src/stores/page.tsx

@@ -2,6 +2,7 @@ import type {
   IPageInfoForEntity, IPagePopulatedToShowRevision, Nullable,
   IPageInfoForEntity, IPagePopulatedToShowRevision, Nullable,
 } from '@growi/core';
 } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
+import { useEffect } from 'react';
 import useSWR, { Key, SWRResponse } from 'swr';
 import useSWR, { Key, SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
 
 
@@ -16,7 +17,7 @@ import { IRevisionsForPagination } from '~/interfaces/revision';
 import { IPageTagsInfo } from '../interfaces/tag';
 import { IPageTagsInfo } from '../interfaces/tag';
 
 
 import { useCurrentPageId, useCurrentPathname, useCurrentRevisionId } from './context';
 import { useCurrentPageId, useCurrentPathname, useCurrentRevisionId } from './context';
-import { ITermNumberManagerUtil, useStaticSWR, useTermNumberManager } from './use-static-swr';
+import { ITermNumberManagerUtil, useTermNumberManager } from './use-static-swr';
 
 
 const { isPermalink: _isPermalink } = pagePathUtils;
 const { isPermalink: _isPermalink } = pagePathUtils;
 
 
@@ -27,7 +28,7 @@ export const useSWRxPage = (
     revisionId?: string,
     revisionId?: string,
     initialData?: IPagePopulatedToShowRevision|null,
     initialData?: IPagePopulatedToShowRevision|null,
 ): SWRResponse<IPagePopulatedToShowRevision|null, Error> => {
 ): SWRResponse<IPagePopulatedToShowRevision|null, Error> => {
-  return useSWR<IPagePopulatedToShowRevision|null, Error>(
+  const swrResponse = useSWR<IPagePopulatedToShowRevision|null, Error>(
     pageId != null ? ['/page', pageId, shareLinkId, revisionId] : null,
     pageId != null ? ['/page', pageId, shareLinkId, revisionId] : null,
     (endpoint, pageId, shareLinkId, revisionId) => apiv3Get<{ page: IPagePopulatedToShowRevision }>(endpoint, { pageId, shareLinkId, revisionId })
     (endpoint, pageId, shareLinkId, revisionId) => apiv3Get<{ page: IPagePopulatedToShowRevision }>(endpoint, { pageId, shareLinkId, revisionId })
       .then(result => result.data.page)
       .then(result => result.data.page)
@@ -40,8 +41,15 @@ export const useSWRxPage = (
         }
         }
         throw Error('failed to get page');
         throw Error('failed to get page');
       }),
       }),
-    { fallbackData: initialData },
   );
   );
+
+  useEffect(() => {
+    if (initialData !== undefined) {
+      swrResponse.mutate(initialData);
+    }
+  }, [initialData, swrResponse]);
+
+  return swrResponse;
 };
 };
 
 
 export const useSWRxPageByPath = (path?: string): SWRResponse<IPagePopulatedToShowRevision, Error> => {
 export const useSWRxPageByPath = (path?: string): SWRResponse<IPagePopulatedToShowRevision, Error> => {

+ 1 - 2
packages/app/src/stores/ui.tsx

@@ -23,7 +23,7 @@ import loggerFactory from '~/utils/logger';
 
 
 import {
 import {
   useCurrentPageId, useIsEditable, useIsGuestUser,
   useCurrentPageId, useIsEditable, useIsGuestUser,
-  useIsSharedUser, useIsIdenticalPath, useCurrentUser, useIsNotFound, useShareLinkId,
+  useIsSharedUser, useIsIdenticalPath, useCurrentUser, useShareLinkId, useIsNotFound,
 } from './context';
 } from './context';
 import { localStorageMiddleware } from './middlewares/sync-to-storage';
 import { localStorageMiddleware } from './middlewares/sync-to-storage';
 import { useCurrentPagePath, useIsTrashPage } from './page';
 import { useCurrentPagePath, useIsTrashPage } from './page';
@@ -396,7 +396,6 @@ export const usePageTreeDescCountMap = (initialData?: UpdateDescCountData): SWRR
   };
   };
 };
 };
 
 
-
 /** **********************************************************
 /** **********************************************************
  *                          SWR Hooks
  *                          SWR Hooks
  *                Determined value by context
  *                Determined value by context

+ 16 - 3
packages/app/src/stores/use-context-swr.tsx

@@ -1,8 +1,10 @@
+import assert from 'assert';
+
 import {
 import {
-  Key, SWRConfiguration, SWRResponse,
+  Key, SWRConfiguration, SWRResponse, useSWRConfig,
 } from 'swr';
 } from 'swr';
+import useSWRImmutable from 'swr/immutable';
 
 
-import { useStaticSWR } from './use-static-swr';
 
 
 export function useContextSWR<Data, Error>(key: Key): SWRResponse<Data, Error>;
 export function useContextSWR<Data, Error>(key: Key): SWRResponse<Data, Error>;
 export function useContextSWR<Data, Error>(key: Key, data: Data | undefined): SWRResponse<Data, Error>;
 export function useContextSWR<Data, Error>(key: Key, data: Data | undefined): SWRResponse<Data, Error>;
@@ -16,7 +18,18 @@ export function useContextSWR<Data, Error>(
 ): SWRResponse<Data, Error> {
 ): SWRResponse<Data, Error> {
   const [key, data, configuration] = args;
   const [key, data, configuration] = args;
 
 
-  const swrResponse = useStaticSWR<Data, Error>(key, data, configuration);
+  assert.notStrictEqual(configuration?.fetcher, null, 'useContextSWR does not support \'configuration.fetcher\'');
+
+  const { cache } = useSWRConfig();
+  const swrResponse = useSWRImmutable(key, null, {
+    ...configuration,
+    fallbackData: configuration?.fallbackData ?? cache.get(key),
+  });
+
+  // write data to cache directly
+  if (data !== undefined) {
+    cache.set(key, data);
+  }
 
 
   const result = Object.assign(swrResponse, { mutate: () => { throw Error('mutate can not be used in context') } });
   const result = Object.assign(swrResponse, { mutate: () => { throw Error('mutate can not be used in context') } });
 
 

+ 11 - 10
packages/app/src/stores/use-static-swr.tsx

@@ -1,7 +1,9 @@
+import { useEffect } from 'react';
+
 import assert from 'assert';
 import assert from 'assert';
 
 
 import {
 import {
-  Key, SWRConfiguration, SWRResponse, useSWRConfig,
+  Key, SWRConfiguration, SWRResponse,
 } from 'swr';
 } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
 
 
@@ -20,16 +22,15 @@ export function useStaticSWR<Data, Error>(
 
 
   assert.notStrictEqual(configuration?.fetcher, null, 'useStaticSWR does not support \'configuration.fetcher\'');
   assert.notStrictEqual(configuration?.fetcher, null, 'useStaticSWR does not support \'configuration.fetcher\'');
 
 
-  const { cache } = useSWRConfig();
-  const swrResponse = useSWRImmutable(key, null, {
-    ...configuration,
-    fallbackData: configuration?.fallbackData ?? cache.get(key),
-  });
+  const swrResponse = useSWRImmutable(key, null, configuration);
 
 
-  // write data to cache directly
-  if (data !== undefined) {
-    cache.set(key, data);
-  }
+  // Do mutate with `data` from args
+  useEffect(() => {
+    if (data !== undefined) {
+      swrResponse.mutate(data);
+    }
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [data]); // Only depends on `data`
 
 
   return swrResponse;
   return swrResponse;
 }
 }