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

Merge branch 'master' into support/156162-176215-app-some-client-components-biome-4

Yuki Takei 3 месяцев назад
Родитель
Сommit
5b3e230d4e
57 измененных файлов с 2516 добавлено и 1563 удалено
  1. 5 0
      apps/app/.eslintrc.js
  2. 1 1
      apps/app/package.json
  3. 224 147
      apps/app/src/client/components/Bookmarks/BookmarkFolderItem.tsx
  4. 27 19
      apps/app/src/client/components/Bookmarks/BookmarkFolderItemControl.tsx
  5. 85 60
      apps/app/src/client/components/Bookmarks/BookmarkFolderMenu.tsx
  6. 11 12
      apps/app/src/client/components/Bookmarks/BookmarkFolderMenuItem.tsx
  7. 39 19
      apps/app/src/client/components/Bookmarks/BookmarkFolderNameInput.tsx
  8. 66 33
      apps/app/src/client/components/Bookmarks/BookmarkFolderTree.tsx
  9. 173 114
      apps/app/src/client/components/Bookmarks/BookmarkItem.tsx
  10. 39 19
      apps/app/src/client/components/Bookmarks/BookmarkItemRenameInput.tsx
  11. 5 4
      apps/app/src/client/components/Bookmarks/BookmarkMoveToRootBtn.tsx
  12. 33 21
      apps/app/src/client/components/Bookmarks/DragAndDropWrapper.tsx
  13. 51 31
      apps/app/src/client/components/InAppNotification/InAppNotificationDropdown.tsx
  14. 14 12
      apps/app/src/client/components/InAppNotification/InAppNotificationElm.tsx
  15. 8 9
      apps/app/src/client/components/InAppNotification/InAppNotificationList.tsx
  16. 105 80
      apps/app/src/client/components/InAppNotification/InAppNotificationPage.tsx
  17. 7 9
      apps/app/src/client/components/InAppNotification/ModelNotification/ModelNotification.tsx
  18. 42 20
      apps/app/src/client/components/InAppNotification/ModelNotification/PageBulkExportJobModelNotification.tsx
  19. 15 14
      apps/app/src/client/components/InAppNotification/ModelNotification/PageModelNotification.tsx
  20. 10 10
      apps/app/src/client/components/InAppNotification/ModelNotification/UserModelNotification.tsx
  21. 13 11
      apps/app/src/client/components/InAppNotification/ModelNotification/index.tsx
  22. 6 4
      apps/app/src/client/components/InAppNotification/ModelNotification/useActionAndMsg.ts
  23. 130 112
      apps/app/src/client/components/Me/AccessTokenForm.tsx
  24. 76 68
      apps/app/src/client/components/Me/AccessTokenList.tsx
  25. 16 8
      apps/app/src/client/components/Me/AccessTokenScopeList.tsx
  26. 20 6
      apps/app/src/client/components/Me/AccessTokenScopeSelect.tsx
  27. 107 82
      apps/app/src/client/components/Me/AccessTokenSettings.tsx
  28. 8 6
      apps/app/src/client/components/Me/ApiSettings.tsx
  29. 34 39
      apps/app/src/client/components/Me/ApiTokenSettings.tsx
  30. 59 35
      apps/app/src/client/components/Me/AssociateModal.tsx
  31. 110 57
      apps/app/src/client/components/Me/BasicInfoSettings.tsx
  32. 50 24
      apps/app/src/client/components/Me/ColorModeSettings.tsx
  33. 51 28
      apps/app/src/client/components/Me/DisassociateModal.tsx
  34. 1 2
      apps/app/src/client/components/Me/EditorSettings.tsx
  35. 29 23
      apps/app/src/client/components/Me/ExternalAccountLinkedMe.jsx
  36. 3 5
      apps/app/src/client/components/Me/ExternalAccountRow.jsx
  37. 54 32
      apps/app/src/client/components/Me/InAppNotificationSettings.tsx
  38. 0 2
      apps/app/src/client/components/Me/OtherSettings.tsx
  39. 71 35
      apps/app/src/client/components/Me/PasswordSettings.jsx
  40. 69 13
      apps/app/src/client/components/Me/PersonalSettings.jsx
  41. 128 59
      apps/app/src/client/components/Me/ProfileImageSettings.tsx
  42. 66 30
      apps/app/src/client/components/Me/UISettings.tsx
  43. 3 2
      apps/app/src/client/components/Me/UserSettings.tsx
  44. 15 15
      apps/app/src/client/components/PageTags/PageTags.tsx
  45. 5 7
      apps/app/src/client/components/PageTags/RenderTagLabels.tsx
  46. 42 24
      apps/app/src/client/components/PageTags/TagEditModal/TagEditModal.tsx
  47. 26 16
      apps/app/src/client/components/PageTags/TagEditModal/TagsInput.tsx
  48. 2 1
      apps/app/src/client/components/PageTags/TagEditModal/dynamic.tsx
  49. 74 55
      apps/app/src/client/components/ReactMarkdownComponents/DrawioViewerWithEditButton.tsx
  50. 73 45
      apps/app/src/client/components/ReactMarkdownComponents/Header.tsx
  51. 14 4
      apps/app/src/client/components/ReactMarkdownComponents/LightBox.tsx
  52. 49 23
      apps/app/src/client/components/ReactMarkdownComponents/RichAttachment.tsx
  53. 47 29
      apps/app/src/client/components/ReactMarkdownComponents/TableWithEditButton.tsx
  54. 9 1
      apps/app/src/features/page-tree/hooks/_inner/use-scroll-to-selected-item.ts
  55. 1 6
      biome.json
  56. 1 1
      packages/slack/package.json
  57. 94 19
      pnpm-lock.yaml

+ 5 - 0
apps/app/.eslintrc.js

@@ -55,6 +55,11 @@ module.exports = {
     'src/client/components/*.jsx',
     'src/client/components/*.jsx',
     'src/client/components/*.ts',
     'src/client/components/*.ts',
     'src/client/components/*.js',
     'src/client/components/*.js',
+    'src/client/components/Me/**',
+    'src/client/components/Bookmarks/**',
+    'src/client/components/InAppNotification/**',
+    'src/client/components/PageTags/**',
+    'src/client/components/ReactMarkdownComponents/**',
     'src/client/components/AuthorInfo/**',
     'src/client/components/AuthorInfo/**',
     'src/client/components/Common/**',
     'src/client/components/Common/**',
     'src/client/components/CreateTemplateModal/**',
     'src/client/components/CreateTemplateModal/**',

+ 1 - 1
apps/app/package.json

@@ -193,7 +193,7 @@
     "passport-saml": "^3.2.0",
     "passport-saml": "^3.2.0",
     "pathe": "^2.0.3",
     "pathe": "^2.0.3",
     "prop-types": "^15.8.1",
     "prop-types": "^15.8.1",
-    "qs": "^6.11.1",
+    "qs": "^6.14.1",
     "rate-limiter-flexible": "^2.3.7",
     "rate-limiter-flexible": "^2.3.7",
     "react": "^18.2.0",
     "react": "^18.2.0",
     "react-bootstrap-typeahead": "^6.3.2",
     "react-bootstrap-typeahead": "^6.3.2",

+ 224 - 147
apps/app/src/client/components/Bookmarks/BookmarkFolderItem.tsx

@@ -1,15 +1,21 @@
 import type { FC } from 'react';
 import type { FC } from 'react';
 import { useCallback, useState } from 'react';
 import { useCallback, useState } from 'react';
-
 import type { IPageToDeleteWithMeta } from '@growi/core';
 import type { IPageToDeleteWithMeta } from '@growi/core';
 import { DropdownToggle } from 'reactstrap';
 import { DropdownToggle } from 'reactstrap';
 
 
 import { FolderIcon } from '~/client/components/Icons/FolderIcon';
 import { FolderIcon } from '~/client/components/Icons/FolderIcon';
 import {
 import {
-  addBookmarkToFolder, addNewFolder, hasChildren, updateBookmarkFolder,
+  addBookmarkToFolder,
+  addNewFolder,
+  hasChildren,
+  updateBookmarkFolder,
 } from '~/client/util/bookmark-utils';
 } from '~/client/util/bookmark-utils';
 import { toastError } from '~/client/util/toastr';
 import { toastError } from '~/client/util/toastr';
-import type { BookmarkFolderItems, DragItemDataType, DragItemType } from '~/interfaces/bookmark-info';
+import type {
+  BookmarkFolderItems,
+  DragItemDataType,
+  DragItemType,
+} from '~/interfaces/bookmark-info';
 import { DRAG_ITEM_TYPE } from '~/interfaces/bookmark-info';
 import { DRAG_ITEM_TYPE } from '~/interfaces/bookmark-info';
 import type { onDeletedBookmarkFolderFunction } from '~/interfaces/ui';
 import type { onDeletedBookmarkFolderFunction } from '~/interfaces/ui';
 import { useDeleteBookmarkFolderModalActions } from '~/states/ui/modal/delete-bookmark-folder';
 import { useDeleteBookmarkFolderModalActions } from '~/states/ui/modal/delete-bookmark-folder';
@@ -20,28 +26,43 @@ import { BookmarkItem } from './BookmarkItem';
 import { DragAndDropWrapper } from './DragAndDropWrapper';
 import { DragAndDropWrapper } from './DragAndDropWrapper';
 
 
 type BookmarkFolderItemProps = {
 type BookmarkFolderItemProps = {
-  isReadOnlyUser: boolean
-  bookmarkFolder: BookmarkFolderItems
-  isOpen?: boolean
-  isOperable: boolean,
-  level: number
-  root: string
-  isUserHomepage?: boolean
-  onClickDeleteMenuItemHandler: (pageToDelete: IPageToDeleteWithMeta) => void
-  bookmarkFolderTreeMutation: () => void
-}
-
-export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkFolderItemProps) => {
+  isReadOnlyUser: boolean;
+  bookmarkFolder: BookmarkFolderItems;
+  isOpen?: boolean;
+  isOperable: boolean;
+  level: number;
+  root: string;
+  isUserHomepage?: boolean;
+  onClickDeleteMenuItemHandler: (pageToDelete: IPageToDeleteWithMeta) => void;
+  bookmarkFolderTreeMutation: () => void;
+};
 
 
+export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (
+  props: BookmarkFolderItemProps,
+) => {
   const BASE_FOLDER_PADDING = 15;
   const BASE_FOLDER_PADDING = 15;
-  const acceptedTypes: DragItemType[] = [DRAG_ITEM_TYPE.FOLDER, DRAG_ITEM_TYPE.BOOKMARK];
+  const acceptedTypes: DragItemType[] = [
+    DRAG_ITEM_TYPE.FOLDER,
+    DRAG_ITEM_TYPE.BOOKMARK,
+  ];
   const {
   const {
-    isReadOnlyUser, bookmarkFolder, isOpen: _isOpen = false, isOperable, level, root, isUserHomepage,
-    onClickDeleteMenuItemHandler, bookmarkFolderTreeMutation,
+    isReadOnlyUser,
+    bookmarkFolder,
+    isOpen: _isOpen = false,
+    isOperable,
+    level,
+    root,
+    isUserHomepage,
+    onClickDeleteMenuItemHandler,
+    bookmarkFolderTreeMutation,
   } = props;
   } = props;
 
 
   const {
   const {
-    name, _id: folderId, childFolder, parent, bookmarks,
+    name,
+    _id: folderId,
+    childFolder,
+    parent,
+    bookmarks,
   } = bookmarkFolder;
   } = bookmarkFolder;
 
 
   const [targetFolder, setTargetFolder] = useState<string | null>(folderId);
   const [targetFolder, setTargetFolder] = useState<string | null>(folderId);
@@ -49,13 +70,14 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   const [isRenameAction, setIsRenameAction] = useState<boolean>(false);
   const [isRenameAction, setIsRenameAction] = useState<boolean>(false);
   const [isCreateAction, setIsCreateAction] = useState<boolean>(false);
   const [isCreateAction, setIsCreateAction] = useState<boolean>(false);
 
 
-  const { open: openDeleteBookmarkFolderModal } = useDeleteBookmarkFolderModalActions();
+  const { open: openDeleteBookmarkFolderModal } =
+    useDeleteBookmarkFolderModalActions();
 
 
   const childrenExists = hasChildren({ childFolder, bookmarks });
   const childrenExists = hasChildren({ childFolder, bookmarks });
 
 
   const paddingLeft = BASE_FOLDER_PADDING * level;
   const paddingLeft = BASE_FOLDER_PADDING * level;
 
 
-  const loadChildFolder = useCallback(async() => {
+  const loadChildFolder = useCallback(async () => {
     setIsOpen(!isOpen);
     setIsOpen(!isOpen);
     setTargetFolder(folderId);
     setTargetFolder(folderId);
   }, [folderId, isOpen]);
   }, [folderId, isOpen]);
@@ -66,95 +88,127 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   }, []);
   }, []);
 
 
   // Rename for bookmark folder handler
   // Rename for bookmark folder handler
-  const rename = useCallback(async(folderName: string) => {
-    if (folderName.trim() === '') {
-      return cancel();
-    }
+  const rename = useCallback(
+    async (folderName: string) => {
+      if (folderName.trim() === '') {
+        return cancel();
+      }
 
 
-    try {
-      // TODO: do not use any type
-      await updateBookmarkFolder(folderId, folderName.trim(), parent as any, childFolder);
-      bookmarkFolderTreeMutation();
-      setIsRenameAction(false);
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [bookmarkFolderTreeMutation, cancel, childFolder, folderId, parent]);
+      try {
+        // TODO: do not use any type
+        await updateBookmarkFolder(
+          folderId,
+          folderName.trim(),
+          parent as any,
+          childFolder,
+        );
+        bookmarkFolderTreeMutation();
+        setIsRenameAction(false);
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [bookmarkFolderTreeMutation, cancel, childFolder, folderId, parent],
+  );
 
 
   // Create new folder / subfolder handler
   // Create new folder / subfolder handler
-  const create = useCallback(async(folderName: string) => {
-    if (folderName.trim() === '') {
-      return cancel();
-    }
+  const create = useCallback(
+    async (folderName: string) => {
+      if (folderName.trim() === '') {
+        return cancel();
+      }
 
 
-    try {
-      await addNewFolder(folderName.trim(), targetFolder);
-      setIsOpen(true);
-      setIsCreateAction(false);
-      bookmarkFolderTreeMutation();
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [bookmarkFolderTreeMutation, cancel, targetFolder]);
+      try {
+        await addNewFolder(folderName.trim(), targetFolder);
+        setIsOpen(true);
+        setIsCreateAction(false);
+        bookmarkFolderTreeMutation();
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [bookmarkFolderTreeMutation, cancel, targetFolder],
+  );
 
 
-  const onClickPlusButton = useCallback(async(e) => {
-    e.stopPropagation();
-    if (!isOpen && childrenExists) {
-      setIsOpen(true);
-    }
-    setIsCreateAction(true);
-  }, [childrenExists, isOpen]);
+  const onClickPlusButton = useCallback(
+    async (e) => {
+      e.stopPropagation();
+      if (!isOpen && childrenExists) {
+        setIsOpen(true);
+      }
+      setIsCreateAction(true);
+    },
+    [childrenExists, isOpen],
+  );
 
 
-  const itemDropHandler = async(item: DragItemDataType, dragItemType: string | symbol | null) => {
+  const itemDropHandler = async (
+    item: DragItemDataType,
+    dragItemType: string | symbol | null,
+  ) => {
     if (dragItemType === DRAG_ITEM_TYPE.FOLDER) {
     if (dragItemType === DRAG_ITEM_TYPE.FOLDER) {
       try {
       try {
         if (item.bookmarkFolder != null) {
         if (item.bookmarkFolder != null) {
-          await updateBookmarkFolder(item.bookmarkFolder._id, item.bookmarkFolder.name, bookmarkFolder._id, item.bookmarkFolder.childFolder);
+          await updateBookmarkFolder(
+            item.bookmarkFolder._id,
+            item.bookmarkFolder.name,
+            bookmarkFolder._id,
+            item.bookmarkFolder.childFolder,
+          );
           bookmarkFolderTreeMutation();
           bookmarkFolderTreeMutation();
         }
         }
-      }
-      catch (err) {
+      } catch (err) {
         toastError(err);
         toastError(err);
       }
       }
-    }
-    else {
+    } else {
       try {
       try {
         if (item != null) {
         if (item != null) {
           await addBookmarkToFolder(item._id, bookmarkFolder._id);
           await addBookmarkToFolder(item._id, bookmarkFolder._id);
           bookmarkFolderTreeMutation();
           bookmarkFolderTreeMutation();
         }
         }
-      }
-      catch (err) {
+      } catch (err) {
         toastError(err);
         toastError(err);
       }
       }
     }
     }
   };
   };
 
 
-  const isDropable = (item: DragItemDataType, type: string | null | symbol): boolean => {
+  const isDropable = (
+    item: DragItemDataType,
+    type: string | null | symbol,
+  ): boolean => {
     if (type === DRAG_ITEM_TYPE.FOLDER) {
     if (type === DRAG_ITEM_TYPE.FOLDER) {
-      if (item.bookmarkFolder.parent === bookmarkFolder._id || item.bookmarkFolder._id === bookmarkFolder._id) {
+      if (
+        item.bookmarkFolder.parent === bookmarkFolder._id ||
+        item.bookmarkFolder._id === bookmarkFolder._id
+      ) {
         return false;
         return false;
       }
       }
 
 
       // Maximum folder hierarchy of 2 levels
       // Maximum folder hierarchy of 2 levels
       // If the drop source folder has child folders, the drop source folder cannot be moved because the drop source folder hierarchy is already 2.
       // If the drop source folder has child folders, the drop source folder cannot be moved because the drop source folder hierarchy is already 2.
       // If the destination folder has a parent, the source folder cannot be moved because the destination folder hierarchy is already 2.
       // If the destination folder has a parent, the source folder cannot be moved because the destination folder hierarchy is already 2.
-      if (item.bookmarkFolder.childFolder.length !== 0 || bookmarkFolder.parent != null) {
+      if (
+        item.bookmarkFolder.childFolder.length !== 0 ||
+        bookmarkFolder.parent != null
+      ) {
         return false;
         return false;
       }
       }
 
 
       return item.root !== root || item.level >= level;
       return item.root !== root || item.level >= level;
     }
     }
 
 
-    if (item.parentFolder != null && item.parentFolder._id === bookmarkFolder._id) {
+    if (
+      item.parentFolder != null &&
+      item.parentFolder._id === bookmarkFolder._id
+    ) {
       return false;
       return false;
     }
     }
     return true;
     return true;
   };
   };
 
 
-  const triangleBtnClassName = (isOpen: boolean, childrenExists: boolean): string => {
+  const triangleBtnClassName = (
+    isOpen: boolean,
+    childrenExists: boolean,
+  ): string => {
     if (!childrenExists) {
     if (!childrenExists) {
       return 'grw-foldertree-triangle-btn btn px-0 opacity-25';
       return 'grw-foldertree-triangle-btn btn px-0 opacity-25';
     }
     }
@@ -162,41 +216,47 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   };
   };
 
 
   const renderChildFolder = () => {
   const renderChildFolder = () => {
-    return isOpen && childFolder?.map((childFolder) => {
-      return (
-        <div key={childFolder._id} className="grw-foldertree-item-children">
-          <BookmarkFolderItem
-            key={childFolder._id}
+    return (
+      isOpen &&
+      childFolder?.map((childFolder) => {
+        return (
+          <div key={childFolder._id} className="grw-foldertree-item-children">
+            <BookmarkFolderItem
+              key={childFolder._id}
+              isReadOnlyUser={isReadOnlyUser}
+              isOperable={props.isOperable}
+              bookmarkFolder={childFolder}
+              level={level + 1}
+              root={root}
+              isUserHomepage={isUserHomepage}
+              onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
+              bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
+            />
+          </div>
+        );
+      })
+    );
+  };
+
+  const renderBookmarkItem = () => {
+    return (
+      isOpen &&
+      bookmarks?.map((bookmark) => {
+        return (
+          <BookmarkItem
+            key={bookmark._id}
             isReadOnlyUser={isReadOnlyUser}
             isReadOnlyUser={isReadOnlyUser}
             isOperable={props.isOperable}
             isOperable={props.isOperable}
-            bookmarkFolder={childFolder}
+            bookmarkedPage={bookmark.page}
             level={level + 1}
             level={level + 1}
-            root={root}
-            isUserHomepage={isUserHomepage}
+            parentFolder={bookmarkFolder}
+            canMoveToRoot
             onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
             onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
             bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
             bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
           />
           />
-        </div>
-      );
-    });
-  };
-
-  const renderBookmarkItem = () => {
-    return isOpen && bookmarks?.map((bookmark) => {
-      return (
-        <BookmarkItem
-          key={bookmark._id}
-          isReadOnlyUser={isReadOnlyUser}
-          isOperable={props.isOperable}
-          bookmarkedPage={bookmark.page}
-          level={level + 1}
-          parentFolder={bookmarkFolder}
-          canMoveToRoot
-          onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
-          bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
-        />
-      );
-    });
+        );
+      })
+    );
   };
   };
 
 
   const onClickRenameHandler = useCallback(() => {
   const onClickRenameHandler = useCallback(() => {
@@ -204,7 +264,9 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   }, []);
   }, []);
 
 
   const onClickDeleteHandler = useCallback(() => {
   const onClickDeleteHandler = useCallback(() => {
-    const bookmarkFolderDeleteHandler: onDeletedBookmarkFolderFunction = (folderId) => {
+    const bookmarkFolderDeleteHandler: onDeletedBookmarkFolderFunction = (
+      folderId,
+    ) => {
       if (typeof folderId !== 'string') {
       if (typeof folderId !== 'string') {
         return;
         return;
       }
       }
@@ -214,21 +276,39 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
     if (bookmarkFolder == null) {
     if (bookmarkFolder == null) {
       return;
       return;
     }
     }
-    openDeleteBookmarkFolderModal(bookmarkFolder, { onDeleted: bookmarkFolderDeleteHandler });
-  }, [bookmarkFolder, bookmarkFolderTreeMutation, openDeleteBookmarkFolderModal]);
-
-  const onClickMoveToRootHandlerForBookmarkFolderItemControl = useCallback(async() => {
-    try {
-      await updateBookmarkFolder(bookmarkFolder._id, bookmarkFolder.name, null, bookmarkFolder.childFolder);
-      bookmarkFolderTreeMutation();
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [bookmarkFolder._id, bookmarkFolder.childFolder, bookmarkFolder.name, bookmarkFolderTreeMutation]);
+    openDeleteBookmarkFolderModal(bookmarkFolder, {
+      onDeleted: bookmarkFolderDeleteHandler,
+    });
+  }, [
+    bookmarkFolder,
+    bookmarkFolderTreeMutation,
+    openDeleteBookmarkFolderModal,
+  ]);
 
 
+  const onClickMoveToRootHandlerForBookmarkFolderItemControl =
+    useCallback(async () => {
+      try {
+        await updateBookmarkFolder(
+          bookmarkFolder._id,
+          bookmarkFolder.name,
+          null,
+          bookmarkFolder.childFolder,
+        );
+        bookmarkFolderTreeMutation();
+      } catch (err) {
+        toastError(err);
+      }
+    }, [
+      bookmarkFolder._id,
+      bookmarkFolder.childFolder,
+      bookmarkFolder.name,
+      bookmarkFolderTreeMutation,
+    ]);
   return (
   return (
-    <div id={`grw-bookmark-folder-item-${folderId}`} className="grw-foldertree-item-container">
+    <div
+      id={`grw-bookmark-folder-item-${folderId}`}
+      className="grw-foldertree-item-container"
+    >
       <DragAndDropWrapper
       <DragAndDropWrapper
         key={folderId}
         key={folderId}
         type={acceptedTypes}
         type={acceptedTypes}
@@ -240,23 +320,8 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
       >
       >
         <li
         <li
           className="list-group-item list-group-item-action border-0 py-2 d-flex align-items-center rounded-1"
           className="list-group-item list-group-item-action border-0 py-2 d-flex align-items-center rounded-1"
-          onClick={loadChildFolder}
           style={{ paddingLeft }}
           style={{ paddingLeft }}
         >
         >
-          <div className="grw-triangle-container d-flex justify-content-center">
-            <button
-              type="button"
-              className={triangleBtnClassName(isOpen, childrenExists)}
-              onClick={loadChildFolder}
-            >
-              <div className="d-flex justify-content-center">
-                <span className="material-symbols-outlined fs-5">arrow_right</span>
-              </div>
-            </button>
-          </div>
-          <div>
-            <FolderIcon isOpen={isOpen} />
-          </div>
           {isRenameAction ? (
           {isRenameAction ? (
             <div className="flex-fill">
             <div className="flex-fill">
               <BookmarkFolderNameInput
               <BookmarkFolderNameInput
@@ -266,27 +331,46 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
               />
               />
             </div>
             </div>
           ) : (
           ) : (
-            <>
+            <button
+              type="button"
+              className="d-flex align-items-center flex-fill border-0 bg-transparent p-0 text-start"
+              onClick={loadChildFolder}
+            >
+              <div className="grw-triangle-container d-flex justify-content-center">
+                <span className={triangleBtnClassName(isOpen, childrenExists)}>
+                  <span className="material-symbols-outlined fs-5">
+                    arrow_right
+                  </span>
+                </span>
+              </div>
+              <div>
+                <FolderIcon isOpen={isOpen} />
+              </div>
               <div className="grw-foldertree-title-anchor ps-1">
               <div className="grw-foldertree-title-anchor ps-1">
                 <p className="text-truncate m-auto">{name}</p>
                 <p className="text-truncate m-auto">{name}</p>
               </div>
               </div>
-            </>
+            </button>
           )}
           )}
           {isOperable && (
           {isOperable && (
             <div className="grw-foldertree-control d-flex">
             <div className="grw-foldertree-control d-flex">
               <BookmarkFolderItemControl
               <BookmarkFolderItemControl
                 onClickRename={onClickRenameHandler}
                 onClickRename={onClickRenameHandler}
                 onClickDelete={onClickDeleteHandler}
                 onClickDelete={onClickDeleteHandler}
-                onClickMoveToRoot={bookmarkFolder.parent != null
-                  ? onClickMoveToRootHandlerForBookmarkFolderItemControl
-                  : undefined
+                onClickMoveToRoot={
+                  bookmarkFolder.parent != null
+                    ? onClickMoveToRootHandlerForBookmarkFolderItemControl
+                    : undefined
                 }
                 }
               >
               >
-                <div onClick={e => e.stopPropagation()}>
-                  <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover me-1">
-                    <span className="material-symbols-outlined">more_vert</span>
-                  </DropdownToggle>
-                </div>
+                <DropdownToggle
+                  color="transparent"
+                  className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover me-1"
+                  onClick={(event) => {
+                    event.stopPropagation();
+                  }}
+                >
+                  <span className="material-symbols-outlined">more_vert</span>
+                </DropdownToggle>
               </BookmarkFolderItemControl>
               </BookmarkFolderItemControl>
               {/* Maximum folder hierarchy of 2 levels */}
               {/* Maximum folder hierarchy of 2 levels */}
               {!(bookmarkFolder.parent != null) && (
               {!(bookmarkFolder.parent != null) && (
@@ -304,17 +388,10 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
         </li>
         </li>
       </DragAndDropWrapper>
       </DragAndDropWrapper>
       {isCreateAction && (
       {isCreateAction && (
-        <BookmarkFolderNameInput
-          onSubmit={create}
-          onCancel={cancel}
-        />
+        <BookmarkFolderNameInput onSubmit={create} onCancel={cancel} />
       )}
       )}
-      {
-        renderChildFolder()
-      }
-      {
-        renderBookmarkItem()
-      }
+      {renderChildFolder()}
+      {renderBookmarkItem()}
     </div>
     </div>
   );
   );
 };
 };

+ 27 - 19
apps/app/src/client/components/Bookmarks/BookmarkFolderItemControl.tsx

@@ -1,15 +1,17 @@
-import React, { useState, type JSX } from 'react';
-
+import React, { type JSX, useState } from 'react';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import {
 import {
-  Dropdown, DropdownItem, DropdownMenu, DropdownToggle,
+  Dropdown,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
 export const BookmarkFolderItemControl: React.FC<{
 export const BookmarkFolderItemControl: React.FC<{
-  children?: React.ReactNode
-  onClickMoveToRoot?: () => Promise<void>
-  onClickRename: () => void
-  onClickDelete: () => void
+  children?: React.ReactNode;
+  onClickMoveToRoot?: () => Promise<void>;
+  onClickRename: () => void;
+  onClickDelete: () => void;
 }> = ({
 }> = ({
   children,
   children,
   onClickMoveToRoot,
   onClickMoveToRoot,
@@ -21,23 +23,25 @@ export const BookmarkFolderItemControl: React.FC<{
 
 
   return (
   return (
     <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)}>
     <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)}>
-      { children ?? (
-        <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control d-flex align-items-center justify-content-center">
+      {children ?? (
+        <DropdownToggle
+          color="transparent"
+          className="border-0 rounded btn-page-item-control d-flex align-items-center justify-content-center"
+        >
           <span className="material-symbols-outlined">more_horiz</span>
           <span className="material-symbols-outlined">more_horiz</span>
         </DropdownToggle>
         </DropdownToggle>
-      ) }
+      )}
 
 
-      { isOpen && (
-        <DropdownMenu
-          container="body"
-          style={{ zIndex: 1055 }}
-        >
+      {isOpen && (
+        <DropdownMenu container="body" style={{ zIndex: 1055 }}>
           {onClickMoveToRoot && (
           {onClickMoveToRoot && (
             <DropdownItem
             <DropdownItem
               onClick={onClickMoveToRoot}
               onClick={onClickMoveToRoot}
               className="grw-page-control-dropdown-item"
               className="grw-page-control-dropdown-item"
             >
             >
-              <span className="material-symbols-outlined grw-page-control-dropdown-icon">bookmark</span>
+              <span className="material-symbols-outlined grw-page-control-dropdown-icon">
+                bookmark
+              </span>
               {t('bookmark_folder.move_to_root')}
               {t('bookmark_folder.move_to_root')}
             </DropdownItem>
             </DropdownItem>
           )}
           )}
@@ -45,7 +49,9 @@ export const BookmarkFolderItemControl: React.FC<{
             onClick={onClickRename}
             onClick={onClickRename}
             className="grw-page-control-dropdown-item"
             className="grw-page-control-dropdown-item"
           >
           >
-            <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">redo</span>
+            <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
+              redo
+            </span>
             {t('Rename')}
             {t('Rename')}
           </DropdownItem>
           </DropdownItem>
 
 
@@ -55,11 +61,13 @@ export const BookmarkFolderItemControl: React.FC<{
             className="pt-2 grw-page-control-dropdown-item text-danger"
             className="pt-2 grw-page-control-dropdown-item text-danger"
             onClick={onClickDelete}
             onClick={onClickDelete}
           >
           >
-            <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">delete</span>
+            <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
+              delete
+            </span>
             {t('Delete')}
             {t('Delete')}
           </DropdownItem>
           </DropdownItem>
         </DropdownMenu>
         </DropdownMenu>
-      ) }
+      )}
     </Dropdown>
     </Dropdown>
   );
   );
 };
 };

+ 85 - 60
apps/app/src/client/components/Bookmarks/BookmarkFolderMenu.tsx

@@ -1,11 +1,11 @@
-import React, {
-  useCallback, useMemo, useState, type JSX,
-} from 'react';
-
+import React, { type JSX, useCallback, useMemo, useState } from 'react';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { DropdownItem, DropdownMenu, UncontrolledDropdown } from 'reactstrap';
 import { DropdownItem, DropdownMenu, UncontrolledDropdown } from 'reactstrap';
 
 
-import { addBookmarkToFolder, toggleBookmark } from '~/client/util/bookmark-utils';
+import {
+  addBookmarkToFolder,
+  toggleBookmark,
+} from '~/client/util/bookmark-utils';
 import { toastError } from '~/client/util/toastr';
 import { toastError } from '~/client/util/toastr';
 import { useCurrentUser } from '~/states/global';
 import { useCurrentUser } from '~/states/global';
 import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
 import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
@@ -17,43 +17,45 @@ import { BookmarkFolderMenuItem } from './BookmarkFolderMenuItem';
 import styles from './BookmarkFolderMenu.module.scss';
 import styles from './BookmarkFolderMenu.module.scss';
 
 
 type BookmarkFolderMenuProps = {
 type BookmarkFolderMenuProps = {
-  isOpen: boolean,
-  pageId: string,
-  isBookmarked: boolean,
-  onToggle?: () => void,
-  onUnbookmark?: () => void,
-  children?: React.ReactNode,
-}
-
-export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element => {
-  const {
-    isOpen, pageId, isBookmarked, onToggle, onUnbookmark, children,
-  } = props;
+  isOpen: boolean;
+  pageId: string;
+  isBookmarked: boolean;
+  onToggle?: () => void;
+  onUnbookmark?: () => void;
+  children?: React.ReactNode;
+};
+
+export const BookmarkFolderMenu = (
+  props: BookmarkFolderMenuProps,
+): JSX.Element => {
+  const { isOpen, pageId, isBookmarked, onToggle, onUnbookmark, children } =
+    props;
 
 
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const [selectedItem, setSelectedItem] = useState<string | null>(null);
   const [selectedItem, setSelectedItem] = useState<string | null>(null);
 
 
   const currentUser = useCurrentUser();
   const currentUser = useCurrentUser();
-  const { data: bookmarkFolders, mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild(currentUser?._id);
+  const { data: bookmarkFolders, mutate: mutateBookmarkFolders } =
+    useSWRxBookmarkFolderAndChild(currentUser?._id);
 
 
-  const { trigger: mutateCurrentUserBookmarks } = useSWRMUTxCurrentUserBookmarks();
+  const { trigger: mutateCurrentUserBookmarks } =
+    useSWRMUTxCurrentUserBookmarks();
   const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(pageId);
   const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(pageId);
 
 
   const isBookmarkFolderExists = useMemo((): boolean => {
   const isBookmarkFolderExists = useMemo((): boolean => {
     return bookmarkFolders != null && bookmarkFolders.length > 0;
     return bookmarkFolders != null && bookmarkFolders.length > 0;
   }, [bookmarkFolders]);
   }, [bookmarkFolders]);
 
 
-  const toggleBookmarkHandler = useCallback(async() => {
+  const toggleBookmarkHandler = useCallback(async () => {
     try {
     try {
       await toggleBookmark(pageId, isBookmarked);
       await toggleBookmark(pageId, isBookmarked);
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
       toastError(err);
     }
     }
   }, [isBookmarked, pageId]);
   }, [isBookmarked, pageId]);
 
 
-  const onUnbookmarkHandler = useCallback(async() => {
+  const onUnbookmarkHandler = useCallback(async () => {
     if (onUnbookmark != null) {
     if (onUnbookmark != null) {
       onUnbookmark();
       onUnbookmark();
     }
     }
@@ -62,9 +64,15 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
     mutateCurrentUserBookmarks();
     mutateCurrentUserBookmarks();
     mutateBookmarkFolders();
     mutateBookmarkFolders();
     mutatePageInfo();
     mutatePageInfo();
-  }, [onUnbookmark, toggleBookmarkHandler, mutateCurrentUserBookmarks, mutateBookmarkFolders, mutatePageInfo]);
-
-  const toggleHandler = useCallback(async() => {
+  }, [
+    onUnbookmark,
+    toggleBookmarkHandler,
+    mutateCurrentUserBookmarks,
+    mutateBookmarkFolders,
+    mutatePageInfo,
+  ]);
+
+  const toggleHandler = useCallback(async () => {
     // on close
     // on close
     if (isOpen && bookmarkFolders != null) {
     if (isOpen && bookmarkFolders != null) {
       bookmarkFolders.forEach((bookmarkFolder) => {
       bookmarkFolders.forEach((bookmarkFolder) => {
@@ -89,29 +97,48 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
         await toggleBookmarkHandler();
         await toggleBookmarkHandler();
         mutateCurrentUserBookmarks();
         mutateCurrentUserBookmarks();
         mutatePageInfo();
         mutatePageInfo();
-      }
-      catch (err) {
+      } catch (err) {
         toastError(err);
         toastError(err);
       }
       }
     }
     }
-  },
-  [isOpen, bookmarkFolders, onToggle, selectedItem, isBookmarked, pageId, toggleBookmarkHandler, mutateCurrentUserBookmarks, mutatePageInfo]);
-
-  const onMenuItemClickHandler = useCallback(async(e, itemId: string) => {
-    e.stopPropagation();
-
-    setSelectedItem(itemId);
+  }, [
+    isOpen,
+    bookmarkFolders,
+    onToggle,
+    selectedItem,
+    isBookmarked,
+    pageId,
+    toggleBookmarkHandler,
+    mutateCurrentUserBookmarks,
+    mutatePageInfo,
+  ]);
+
+  const onMenuItemClickHandler = useCallback(
+    async (e, itemId: string) => {
+      e.stopPropagation();
+
+      setSelectedItem(itemId);
 
 
-    try {
-      await addBookmarkToFolder(pageId, itemId === 'root' ? null : itemId);
-      mutateCurrentUserBookmarks();
-      mutateBookmarkFolders();
-      mutatePageInfo();
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [pageId, mutateCurrentUserBookmarks, mutateBookmarkFolders, mutatePageInfo]);
+      try {
+        await addBookmarkToFolder(pageId, itemId === 'root' ? null : itemId);
+        mutateCurrentUserBookmarks();
+        mutateBookmarkFolders();
+        mutatePageInfo();
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [pageId, mutateCurrentUserBookmarks, mutateBookmarkFolders, mutatePageInfo],
+  );
+  const onMenuItemKeyDownHandler = useCallback(
+    (itemId: string) => (event: React.KeyboardEvent<HTMLDivElement>) => {
+      if (event.key === 'Enter' || event.key === ' ') {
+        event.preventDefault();
+        onMenuItemClickHandler(event, itemId);
+      }
+    },
+    [onMenuItemClickHandler],
+  );
 
 
   const renderBookmarkMenuItem = () => {
   const renderBookmarkMenuItem = () => {
     return (
     return (
@@ -122,9 +149,7 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
           className="grw-bookmark-folder-menu-item text-danger text-truncate"
           className="grw-bookmark-folder-menu-item text-danger text-truncate"
         >
         >
           <span className="material-symbols-outlined">bookmark</span>{' '}
           <span className="material-symbols-outlined">bookmark</span>{' '}
-          <span className="mx-2">
-            {t('bookmark_folder.cancel_bookmark')}
-          </span>
+          <span className="mx-2">{t('bookmark_folder.cancel_bookmark')}</span>
         </DropdownItem>
         </DropdownItem>
 
 
         {isBookmarkFolderExists && (
         {isBookmarkFolderExists && (
@@ -135,7 +160,8 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
                 className="dropdown-item grw-bookmark-folder-menu-item list-group-item list-group-item-action px-4"
                 className="dropdown-item grw-bookmark-folder-menu-item list-group-item list-group-item-action px-4"
                 tabIndex={0}
                 tabIndex={0}
                 role="menuitem"
                 role="menuitem"
-                onClick={e => onMenuItemClickHandler(e, 'root')}
+                onClick={(e) => onMenuItemClickHandler(e, 'root')}
+                onKeyDown={onMenuItemKeyDownHandler('root')}
               >
               >
                 <BookmarkFolderMenuItem
                 <BookmarkFolderMenuItem
                   itemId="root"
                   itemId="root"
@@ -144,13 +170,14 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
                 />
                 />
               </div>
               </div>
             </div>
             </div>
-            {bookmarkFolders?.map(folder => (
+            {bookmarkFolders?.map((folder) => (
               <React.Fragment key={`bookmark-folders-${folder._id}`}>
               <React.Fragment key={`bookmark-folders-${folder._id}`}>
                 <div
                 <div
                   className="dropdown-item grw-bookmark-folder-menu-item grw-bookmark-folder-menu-item-folder-first list-group-item list-group-item-action"
                   className="dropdown-item grw-bookmark-folder-menu-item grw-bookmark-folder-menu-item-folder-first list-group-item list-group-item-action"
                   tabIndex={0}
                   tabIndex={0}
                   role="menuitem"
                   role="menuitem"
-                  onClick={e => onMenuItemClickHandler(e, folder._id)}
+                  onClick={(e) => onMenuItemClickHandler(e, folder._id)}
+                  onKeyDown={onMenuItemKeyDownHandler(folder._id)}
                 >
                 >
                   <BookmarkFolderMenuItem
                   <BookmarkFolderMenuItem
                     itemId={folder._id}
                     itemId={folder._id}
@@ -158,13 +185,14 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
                     isSelected={selectedItem === folder._id}
                     isSelected={selectedItem === folder._id}
                   />
                   />
                 </div>
                 </div>
-                {folder.childFolder?.map(child => (
+                {folder.childFolder?.map((child) => (
                   <div key={child._id}>
                   <div key={child._id}>
                     <div
                     <div
                       className="dropdown-item grw-bookmark-folder-menu-item grw-bookmark-folder-menu-item-folder-second list-group-item list-group-item-action"
                       className="dropdown-item grw-bookmark-folder-menu-item grw-bookmark-folder-menu-item-folder-second list-group-item list-group-item-action"
                       tabIndex={0}
                       tabIndex={0}
                       role="menuitem"
                       role="menuitem"
-                      onClick={e => onMenuItemClickHandler(e, child._id)}
+                      onClick={(e) => onMenuItemClickHandler(e, child._id)}
+                      onKeyDown={onMenuItemKeyDownHandler(child._id)}
                     >
                     >
                       <BookmarkFolderMenuItem
                       <BookmarkFolderMenuItem
                         itemId={child._id}
                         itemId={child._id}
@@ -183,13 +211,10 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
   };
   };
 
 
   return (
   return (
-    <UncontrolledDropdown
-      isOpen={isOpen}
-      onToggle={toggleHandler}
-    >
+    <UncontrolledDropdown isOpen={isOpen} onToggle={toggleHandler}>
       {children}
       {children}
 
 
-      { isOpen && (
+      {isOpen && (
         <DropdownMenu
         <DropdownMenu
           end
           end
           persist
           persist
@@ -197,9 +222,9 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
           container="body"
           container="body"
           className={`grw-bookmark-folder-menu ${styles['grw-bookmark-folder-menu']}`}
           className={`grw-bookmark-folder-menu ${styles['grw-bookmark-folder-menu']}`}
         >
         >
-          { renderBookmarkMenuItem() }
+          {renderBookmarkMenuItem()}
         </DropdownMenu>
         </DropdownMenu>
-      ) }
+      )}
     </UncontrolledDropdown>
     </UncontrolledDropdown>
   );
   );
 };
 };

+ 11 - 12
apps/app/src/client/components/Bookmarks/BookmarkFolderMenuItem.tsx

@@ -1,14 +1,10 @@
-import React from 'react';
+import type React from 'react';
 
 
 export const BookmarkFolderMenuItem: React.FC<{
 export const BookmarkFolderMenuItem: React.FC<{
-  itemId: string
-  itemName: string
-  isSelected: boolean
-}> = ({
-  itemId,
-  itemName,
-  isSelected,
-}) => {
+  itemId: string;
+  itemName: string;
+  isSelected: boolean;
+}> = ({ itemId, itemName, isSelected }) => {
   return (
   return (
     <div className="d-flex align-items-center grw-bookmark-folder-menu-item-title">
     <div className="d-flex align-items-center grw-bookmark-folder-menu-item-title">
       <input
       <input
@@ -16,10 +12,13 @@ export const BookmarkFolderMenuItem: React.FC<{
         checked={isSelected}
         checked={isSelected}
         name="bookmark-folder-menu-item"
         name="bookmark-folder-menu-item"
         id={`bookmark-folder-menu-item-${itemId}`}
         id={`bookmark-folder-menu-item-${itemId}`}
-        onChange={e => e.stopPropagation()}
-        onClick={e => e.stopPropagation()}
+        onChange={(e) => e.stopPropagation()}
+        onClick={(e) => e.stopPropagation()}
       />
       />
-      <label htmlFor={`bookmark-folder-menu-item-${itemId}`} className="p-2 m-0 form-label text-truncate">
+      <label
+        htmlFor={`bookmark-folder-menu-item-${itemId}`}
+        className="p-2 m-0 form-label text-truncate"
+      >
         {itemName}
         {itemName}
       </label>
       </label>
     </div>
     </div>

+ 39 - 19
apps/app/src/client/components/Bookmarks/BookmarkFolderNameInput.tsx

@@ -1,19 +1,26 @@
 import type { ChangeEvent, JSX } from 'react';
 import type { ChangeEvent, JSX } from 'react';
 import { useCallback, useRef, useState } from 'react';
 import { useCallback, useRef, useState } from 'react';
-
 import { useRect } from '@growi/ui/dist/utils';
 import { useRect } from '@growi/ui/dist/utils';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import type { AutosizeInputProps } from 'react-input-autosize';
 import type { AutosizeInputProps } from 'react-input-autosize';
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
 
 
 import type { InputValidationResult } from '~/client/util/use-input-validator';
 import type { InputValidationResult } from '~/client/util/use-input-validator';
-import { ValidationTarget, useInputValidator } from '~/client/util/use-input-validator';
-
-import { AutosizeSubmittableInput, getAdjustedMaxWidthForAutosizeInput } from '../Common/SubmittableInput';
+import {
+  useInputValidator,
+  ValidationTarget,
+} from '~/client/util/use-input-validator';
+
+import {
+  AutosizeSubmittableInput,
+  getAdjustedMaxWidthForAutosizeInput,
+} from '../Common/SubmittableInput';
 import type { SubmittableInputProps } from '../Common/SubmittableInput/types';
 import type { SubmittableInputProps } from '../Common/SubmittableInput/types';
 
 
-
-type Props = Pick<SubmittableInputProps<AutosizeInputProps>, 'value' | 'onSubmit' | 'onCancel'>;
+type Props = Pick<
+  SubmittableInputProps<AutosizeInputProps>,
+  'value' | 'onSubmit' | 'onCancel'
+>;
 
 
 export const BookmarkFolderNameInput = (props: Props): JSX.Element => {
 export const BookmarkFolderNameInput = (props: Props): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -23,15 +30,18 @@ export const BookmarkFolderNameInput = (props: Props): JSX.Element => {
   const parentRef = useRef<HTMLDivElement>(null);
   const parentRef = useRef<HTMLDivElement>(null);
   const [parentRect] = useRect(parentRef);
   const [parentRect] = useRect(parentRef);
 
 
-  const [validationResult, setValidationResult] = useState<InputValidationResult>();
-
+  const [validationResult, setValidationResult] =
+    useState<InputValidationResult>();
 
 
   const inputValidator = useInputValidator(ValidationTarget.FOLDER);
   const inputValidator = useInputValidator(ValidationTarget.FOLDER);
 
 
-  const changeHandler = useCallback(async(e: ChangeEvent<HTMLInputElement>) => {
-    const validationResult = inputValidator(e.target.value);
-    setValidationResult(validationResult ?? undefined);
-  }, [inputValidator]);
+  const changeHandler = useCallback(
+    async (e: ChangeEvent<HTMLInputElement>) => {
+      const validationResult = inputValidator(e.target.value);
+      setValidationResult(validationResult ?? undefined);
+    },
+    [inputValidator],
+  );
   const changeHandlerDebounced = debounce(300, changeHandler);
   const changeHandlerDebounced = debounce(300, changeHandler);
 
 
   const cancelHandler = useCallback(() => {
   const cancelHandler = useCallback(() => {
@@ -41,9 +51,14 @@ export const BookmarkFolderNameInput = (props: Props): JSX.Element => {
 
 
   const isInvalid = validationResult != null;
   const isInvalid = validationResult != null;
 
 
-  const maxWidth = parentRect != null
-    ? getAdjustedMaxWidthForAutosizeInput(parentRect.width, 'md', validationResult != null ? false : undefined)
-    : undefined;
+  const maxWidth =
+    parentRect != null
+      ? getAdjustedMaxWidthForAutosizeInput(
+          parentRect.width,
+          'md',
+          validationResult != null ? false : undefined,
+        )
+      : undefined;
 
 
   return (
   return (
     <div ref={parentRef}>
     <div ref={parentRef}>
@@ -52,17 +67,22 @@ export const BookmarkFolderNameInput = (props: Props): JSX.Element => {
         inputClassName={`form-control ${isInvalid ? 'is-invalid' : ''}`}
         inputClassName={`form-control ${isInvalid ? 'is-invalid' : ''}`}
         inputStyle={{ maxWidth }}
         inputStyle={{ maxWidth }}
         placeholder={t('bookmark_folder.input_placeholder')}
         placeholder={t('bookmark_folder.input_placeholder')}
-        aria-describedby={isInvalid ? 'bookmark-folder-name-input-feedback' : undefined}
+        aria-describedby={
+          isInvalid ? 'bookmark-folder-name-input-feedback' : undefined
+        }
         autoFocus
         autoFocus
         onChange={changeHandlerDebounced}
         onChange={changeHandlerDebounced}
         onSubmit={onSubmit}
         onSubmit={onSubmit}
         onCancel={cancelHandler}
         onCancel={cancelHandler}
       />
       />
-      { isInvalid && (
-        <div id="bookmark-folder-name-input-feedback" className="invalid-feedback d-block my-1">
+      {isInvalid && (
+        <div
+          id="bookmark-folder-name-input-feedback"
+          className="invalid-feedback d-block my-1"
+        >
           {validationResult.message}
           {validationResult.message}
         </div>
         </div>
-      ) }
+      )}
     </div>
     </div>
   );
   );
 };
 };

+ 66 - 33
apps/app/src/client/components/Bookmarks/BookmarkFolderTree.tsx

@@ -1,9 +1,8 @@
-
-import React, { useCallback } from 'react';
-
+import type React from 'react';
+import { useCallback } from 'react';
+import { useRouter } from 'next/router';
 import type { IPageToDeleteWithMeta } from '@growi/core';
 import type { IPageToDeleteWithMeta } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-import { useRouter } from 'next/router';
 import { DndProvider } from 'react-dnd';
 import { DndProvider } from 'react-dnd';
 import { HTML5Backend } from 'react-dnd-html5-backend';
 import { HTML5Backend } from 'react-dnd-html5-backend';
 
 
@@ -13,7 +12,8 @@ import { useIsReadOnlyUser } from '~/states/context';
 import { useCurrentPageData } from '~/states/page';
 import { useCurrentPageData } from '~/states/page';
 import { usePageDeleteModalActions } from '~/states/ui/modal/page-delete';
 import { usePageDeleteModalActions } from '~/states/ui/modal/page-delete';
 import {
 import {
-  useSWRxUserBookmarks, useSWRMUTxCurrentUserBookmarks,
+  useSWRMUTxCurrentUserBookmarks,
+  useSWRxUserBookmarks,
 } from '~/stores/bookmark';
 } from '~/stores/bookmark';
 import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
 import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
 import { mutateAllPageInfo, useSWRMUTxPageInfo } from '~/stores/page';
 import { mutateAllPageInfo, useSWRMUTxPageInfo } from '~/stores/page';
@@ -30,10 +30,10 @@ import styles from './BookmarkFolderTree.module.scss';
 //  } & IPageHasId
 //  } & IPageHasId
 
 
 type Props = {
 type Props = {
-  isUserHomepage?: boolean,
-  userId?: string,
-  isOperable: boolean,
-}
+  isUserHomepage?: boolean;
+  userId?: string;
+  isOperable: boolean;
+};
 
 
 export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
 export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
   const { isUserHomepage, userId } = props;
   const { isUserHomepage, userId } = props;
@@ -44,10 +44,15 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
 
 
   const isReadOnlyUser = useIsReadOnlyUser();
   const isReadOnlyUser = useIsReadOnlyUser();
   const currentPage = useCurrentPageData();
   const currentPage = useCurrentPageData();
-  const { data: bookmarkFolders, mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild(userId);
-  const { data: userBookmarks, mutate: mutateUserBookmarks } = useSWRxUserBookmarks(userId ?? null);
-  const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(currentPage?._id ?? null);
-  const { trigger: mutateCurrentUserBookmarks } = useSWRMUTxCurrentUserBookmarks();
+  const { data: bookmarkFolders, mutate: mutateBookmarkFolders } =
+    useSWRxBookmarkFolderAndChild(userId);
+  const { data: userBookmarks, mutate: mutateUserBookmarks } =
+    useSWRxUserBookmarks(userId ?? null);
+  const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(
+    currentPage?._id ?? null,
+  );
+  const { trigger: mutateCurrentUserBookmarks } =
+    useSWRMUTxCurrentUserBookmarks();
   const { open: openDeleteModal } = usePageDeleteModalActions();
   const { open: openDeleteModal } = usePageDeleteModalActions();
 
 
   const bookmarkFolderTreeMutation = useCallback(() => {
   const bookmarkFolderTreeMutation = useCallback(() => {
@@ -55,20 +60,43 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
     mutateCurrentUserBookmarks();
     mutateCurrentUserBookmarks();
     mutatePageInfo();
     mutatePageInfo();
     mutateBookmarkFolders();
     mutateBookmarkFolders();
-  }, [mutateBookmarkFolders, mutatePageInfo, mutateCurrentUserBookmarks, mutateUserBookmarks]);
-
-  const onClickDeleteMenuItemHandler = useCallback((pageToDelete: IPageToDeleteWithMeta) => {
-    const pageDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, _isRecursively, isCompletely) => {
-      if (typeof pathOrPathsToDelete !== 'string') return;
-      toastSuccess(isCompletely ? t('deleted_pages_completely', { path: pathOrPathsToDelete }) : t('deleted_pages', { path: pathOrPathsToDelete }));
-      bookmarkFolderTreeMutation();
-      mutateAllPageInfo();
-      if (pageToDelete.data._id === currentPage?._id && _isRecursively) {
-        router.push(`/trash${currentPage.path}`);
-      }
-    };
-    openDeleteModal([pageToDelete], { onDeleted: pageDeletedHandler });
-  }, [openDeleteModal, t, bookmarkFolderTreeMutation, currentPage?._id, currentPage?.path, router]);
+  }, [
+    mutateBookmarkFolders,
+    mutatePageInfo,
+    mutateCurrentUserBookmarks,
+    mutateUserBookmarks,
+  ]);
+
+  const onClickDeleteMenuItemHandler = useCallback(
+    (pageToDelete: IPageToDeleteWithMeta) => {
+      const pageDeletedHandler: OnDeletedFunction = (
+        pathOrPathsToDelete,
+        _isRecursively,
+        isCompletely,
+      ) => {
+        if (typeof pathOrPathsToDelete !== 'string') return;
+        toastSuccess(
+          isCompletely
+            ? t('deleted_pages_completely', { path: pathOrPathsToDelete })
+            : t('deleted_pages', { path: pathOrPathsToDelete }),
+        );
+        bookmarkFolderTreeMutation();
+        mutateAllPageInfo();
+        if (pageToDelete.data._id === currentPage?._id && _isRecursively) {
+          router.push(`/trash${currentPage.path}`);
+        }
+      };
+      openDeleteModal([pageToDelete], { onDeleted: pageDeletedHandler });
+    },
+    [
+      openDeleteModal,
+      t,
+      bookmarkFolderTreeMutation,
+      currentPage?._id,
+      currentPage?.path,
+      router,
+    ],
+  );
 
 
   /* TODO: update in bookmarks folder v2. */
   /* TODO: update in bookmarks folder v2. */
   // const itemDropHandler = async(item: DragItemDataType, dragType: string | null | symbol) => {
   // const itemDropHandler = async(item: DragItemDataType, dragType: string | null | symbol) => {
@@ -106,9 +134,12 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
 
 
   return (
   return (
     <DndProvider backend={HTML5Backend}>
     <DndProvider backend={HTML5Backend}>
-
-      <div className={`grw-folder-tree-container ${styles['grw-folder-tree-container']}`}>
-        <ul className={`grw-foldertree ${styles['grw-foldertree']} list-group py-2`}>
+      <div
+        className={`grw-folder-tree-container ${styles['grw-folder-tree-container']}`}
+      >
+        <ul
+          className={`grw-foldertree ${styles['grw-foldertree']} list-group py-2`}
+        >
           {bookmarkFolders?.map((bookmarkFolder) => {
           {bookmarkFolders?.map((bookmarkFolder) => {
             return (
             return (
               <BookmarkFolderItem
               <BookmarkFolderItem
@@ -125,8 +156,11 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
               />
               />
             );
             );
           })}
           })}
-          {userBookmarks?.map(userBookmark => (
-            <div key={userBookmark?._id} className="grw-foldertree-item-container grw-root-bookmarks">
+          {userBookmarks?.map((userBookmark) => (
+            <div
+              key={userBookmark?._id}
+              className="grw-foldertree-item-container grw-root-bookmarks"
+            >
               <BookmarkItem
               <BookmarkItem
                 isReadOnlyUser={!!isReadOnlyUser}
                 isReadOnlyUser={!!isReadOnlyUser}
                 isOperable={props.isOperable}
                 isOperable={props.isOperable}
@@ -156,7 +190,6 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
           </DragAndDropWrapper>
           </DragAndDropWrapper>
         )} */}
         )} */}
       </div>
       </div>
-
     </DndProvider>
     </DndProvider>
   );
   );
 };
 };

+ 173 - 114
apps/app/src/client/components/Bookmarks/BookmarkItem.tsx

@@ -1,44 +1,48 @@
-import React, {
-  useCallback, useMemo, useState, type JSX,
-} from 'react';
-
-import nodePath from 'path';
-
-import type { IPageHasId, IPageInfoExt, IPageToDeleteWithMeta } from '@growi/core';
+import React, { type JSX, useCallback, useMemo, useState } from 'react';
+import { useRouter } from 'next/router';
+import type {
+  IPageHasId,
+  IPageInfoExt,
+  IPageToDeleteWithMeta,
+} from '@growi/core';
 import { getIdStringForRef } from '@growi/core';
 import { getIdStringForRef } from '@growi/core';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { pathUtils } from '@growi/core/dist/utils';
 import { pathUtils } from '@growi/core/dist/utils';
-import { useRouter } from 'next/router';
+import nodePath from 'path';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { UncontrolledTooltip, DropdownToggle } from 'reactstrap';
-
+import { DropdownToggle, UncontrolledTooltip } from 'reactstrap';
 
 
 import { bookmark, unbookmark, unlink } from '~/client/services/page-operation';
 import { bookmark, unbookmark, unlink } from '~/client/services/page-operation';
 import { addBookmarkToFolder, renamePage } from '~/client/util/bookmark-utils';
 import { addBookmarkToFolder, renamePage } from '~/client/util/bookmark-utils';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { toastError, toastSuccess } from '~/client/util/toastr';
-import type { BookmarkFolderItems, DragItemDataType } from '~/interfaces/bookmark-info';
+import type {
+  BookmarkFolderItems,
+  DragItemDataType,
+} from '~/interfaces/bookmark-info';
 import { DRAG_ITEM_TYPE } from '~/interfaces/bookmark-info';
 import { DRAG_ITEM_TYPE } from '~/interfaces/bookmark-info';
 import { useFetchCurrentPage } from '~/states/page';
 import { useFetchCurrentPage } from '~/states/page';
 import { usePutBackPageModalActions } from '~/states/ui/modal/put-back-page';
 import { usePutBackPageModalActions } from '~/states/ui/modal/put-back-page';
 import { mutateAllPageInfo, useSWRxPageInfo } from '~/stores/page';
 import { mutateAllPageInfo, useSWRxPageInfo } from '~/stores/page';
 
 
-import { MenuItemType, PageItemControl } from '../Common/Dropdown/PageItemControl';
+import {
+  MenuItemType,
+  PageItemControl,
+} from '../Common/Dropdown/PageItemControl';
 import { PageListItemS } from '../PageList/PageListItemS';
 import { PageListItemS } from '../PageList/PageListItemS';
-
 import { BookmarkItemRenameInput } from './BookmarkItemRenameInput';
 import { BookmarkItemRenameInput } from './BookmarkItemRenameInput';
 import { BookmarkMoveToRootBtn } from './BookmarkMoveToRootBtn';
 import { BookmarkMoveToRootBtn } from './BookmarkMoveToRootBtn';
 import { DragAndDropWrapper } from './DragAndDropWrapper';
 import { DragAndDropWrapper } from './DragAndDropWrapper';
 
 
 type Props = {
 type Props = {
-  isReadOnlyUser: boolean
-  isOperable: boolean,
-  bookmarkedPage: IPageHasId | null,
-  level: number,
-  parentFolder: BookmarkFolderItems | null,
-  canMoveToRoot: boolean,
-  onClickDeleteMenuItemHandler: (pageToDelete: IPageToDeleteWithMeta) => void,
-  bookmarkFolderTreeMutation: () => void,
-}
+  isReadOnlyUser: boolean;
+  isOperable: boolean;
+  bookmarkedPage: IPageHasId | null;
+  level: number;
+  parentFolder: BookmarkFolderItems | null;
+  canMoveToRoot: boolean;
+  onClickDeleteMenuItemHandler: (pageToDelete: IPageToDeleteWithMeta) => void;
+  bookmarkFolderTreeMutation: () => void;
+};
 
 
 export const BookmarkItem = (props: Props): JSX.Element => {
 export const BookmarkItem = (props: Props): JSX.Element => {
   const BASE_FOLDER_PADDING = 15;
   const BASE_FOLDER_PADDING = 15;
@@ -48,46 +52,56 @@ export const BookmarkItem = (props: Props): JSX.Element => {
   const router = useRouter();
   const router = useRouter();
 
 
   const {
   const {
-    isReadOnlyUser, isOperable, bookmarkedPage, onClickDeleteMenuItemHandler,
-    parentFolder, level, canMoveToRoot, bookmarkFolderTreeMutation,
+    isReadOnlyUser,
+    isOperable,
+    bookmarkedPage,
+    onClickDeleteMenuItemHandler,
+    parentFolder,
+    level,
+    canMoveToRoot,
+    bookmarkFolderTreeMutation,
   } = props;
   } = props;
   const { open: openPutBackPageModal } = usePutBackPageModalActions();
   const { open: openPutBackPageModal } = usePutBackPageModalActions();
   const [isRenameInputShown, setRenameInputShown] = useState(false);
   const [isRenameInputShown, setRenameInputShown] = useState(false);
 
 
-  const { data: pageInfo, mutate: mutatePageInfo } = useSWRxPageInfo(bookmarkedPage?._id);
+  const { data: pageInfo, mutate: mutatePageInfo } = useSWRxPageInfo(
+    bookmarkedPage?._id,
+  );
   const { fetchCurrentPage } = useFetchCurrentPage();
   const { fetchCurrentPage } = useFetchCurrentPage();
 
 
-  const paddingLeft = BASE_BOOKMARK_PADDING + (BASE_FOLDER_PADDING * (level));
+  const paddingLeft = BASE_BOOKMARK_PADDING + BASE_FOLDER_PADDING * level;
   const dragItem: Partial<DragItemDataType> = {
   const dragItem: Partial<DragItemDataType> = {
-    ...bookmarkedPage, parentFolder,
+    ...bookmarkedPage,
+    parentFolder,
   };
   };
 
 
   const bookmarkedPageId = bookmarkedPage?._id;
   const bookmarkedPageId = bookmarkedPage?._id;
   const bookmarkedPagePath = bookmarkedPage?.path;
   const bookmarkedPagePath = bookmarkedPage?.path;
   const bookmarkedPageRevision = bookmarkedPage?.revision;
   const bookmarkedPageRevision = bookmarkedPage?.revision;
 
 
-  const onClickMoveToRootHandler = useCallback(async() => {
+  const onClickMoveToRootHandler = useCallback(async () => {
     if (bookmarkedPageId == null) return;
     if (bookmarkedPageId == null) return;
 
 
     try {
     try {
       await addBookmarkToFolder(bookmarkedPageId, null);
       await addBookmarkToFolder(bookmarkedPageId, null);
       bookmarkFolderTreeMutation();
       bookmarkFolderTreeMutation();
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
       toastError(err);
     }
     }
   }, [bookmarkFolderTreeMutation, bookmarkedPageId]);
   }, [bookmarkFolderTreeMutation, bookmarkedPageId]);
 
 
-  const bookmarkMenuItemClickHandler = useCallback(async(pageId: string, shouldBookmark: boolean) => {
-    if (shouldBookmark) {
-      await bookmark(pageId);
-    }
-    else {
-      await unbookmark(pageId);
-    }
-    bookmarkFolderTreeMutation();
-    mutatePageInfo();
-  }, [bookmarkFolderTreeMutation, mutatePageInfo]);
+  const bookmarkMenuItemClickHandler = useCallback(
+    async (pageId: string, shouldBookmark: boolean) => {
+      if (shouldBookmark) {
+        await bookmark(pageId);
+      } else {
+        await unbookmark(pageId);
+      }
+      bookmarkFolderTreeMutation();
+      mutatePageInfo();
+    },
+    [bookmarkFolderTreeMutation, mutatePageInfo],
+  );
 
 
   const renameMenuItemClickHandler = useCallback(() => {
   const renameMenuItemClickHandler = useCallback(() => {
     setRenameInputShown(true);
     setRenameInputShown(true);
@@ -97,57 +111,81 @@ export const BookmarkItem = (props: Props): JSX.Element => {
     setRenameInputShown(false);
     setRenameInputShown(false);
   }, []);
   }, []);
 
 
-  const rename = useCallback(async(inputText: string) => {
-    if (bookmarkedPageId == null) return;
-
+  const rename = useCallback(
+    async (inputText: string) => {
+      if (bookmarkedPageId == null) return;
 
 
-    if (inputText.trim() === '') {
-      return cancel();
-    }
+      if (inputText.trim() === '') {
+        return cancel();
+      }
 
 
-    const parentPath = pathUtils.addTrailingSlash(nodePath.dirname(bookmarkedPagePath ?? ''));
-    const newPagePath = nodePath.resolve(parentPath, inputText.trim());
-    if (newPagePath === bookmarkedPagePath) {
-      setRenameInputShown(false);
-      return;
-    }
+      const parentPath = pathUtils.addTrailingSlash(
+        nodePath.dirname(bookmarkedPagePath ?? ''),
+      );
+      const newPagePath = nodePath.resolve(parentPath, inputText.trim());
+      if (newPagePath === bookmarkedPagePath) {
+        setRenameInputShown(false);
+        return;
+      }
 
 
-    try {
-      setRenameInputShown(false);
-      await renamePage(bookmarkedPageId, bookmarkedPageRevision, newPagePath);
-      bookmarkFolderTreeMutation();
-      mutatePageInfo();
-    }
-    catch (err) {
-      setRenameInputShown(true);
-      toastError(err);
-    }
-  }, [bookmarkedPageId, bookmarkedPagePath, bookmarkedPageRevision, cancel, bookmarkFolderTreeMutation, mutatePageInfo]);
+      try {
+        setRenameInputShown(false);
+        await renamePage(bookmarkedPageId, bookmarkedPageRevision, newPagePath);
+        bookmarkFolderTreeMutation();
+        mutatePageInfo();
+      } catch (err) {
+        setRenameInputShown(true);
+        toastError(err);
+      }
+    },
+    [
+      bookmarkedPageId,
+      bookmarkedPagePath,
+      bookmarkedPageRevision,
+      cancel,
+      bookmarkFolderTreeMutation,
+      mutatePageInfo,
+    ],
+  );
 
 
-  const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoExt | undefined): Promise<void> => {
-    if (bookmarkedPageId == null) return;
+  const deleteMenuItemClickHandler = useCallback(
+    async (
+      _pageId: string,
+      pageInfo: IPageInfoExt | undefined,
+    ): Promise<void> => {
+      if (bookmarkedPageId == null) return;
 
 
-    if (bookmarkedPageId == null || bookmarkedPagePath == null) {
-      throw Error('_id and path must not be null.');
-    }
+      if (bookmarkedPageId == null || bookmarkedPagePath == null) {
+        throw Error('_id and path must not be null.');
+      }
 
 
-    const pageToDelete: IPageToDeleteWithMeta = {
-      data: {
-        _id: bookmarkedPageId,
-        revision: bookmarkedPageRevision == null ? null : getIdStringForRef(bookmarkedPageRevision),
-        path: bookmarkedPagePath,
-      },
-      meta: pageInfo,
-    };
+      const pageToDelete: IPageToDeleteWithMeta = {
+        data: {
+          _id: bookmarkedPageId,
+          revision:
+            bookmarkedPageRevision == null
+              ? null
+              : getIdStringForRef(bookmarkedPageRevision),
+          path: bookmarkedPagePath,
+        },
+        meta: pageInfo,
+      };
 
 
-    onClickDeleteMenuItemHandler(pageToDelete);
-  }, [bookmarkedPageId, bookmarkedPagePath, bookmarkedPageRevision, onClickDeleteMenuItemHandler]);
+      onClickDeleteMenuItemHandler(pageToDelete);
+    },
+    [
+      bookmarkedPageId,
+      bookmarkedPagePath,
+      bookmarkedPageRevision,
+      onClickDeleteMenuItemHandler,
+    ],
+  );
 
 
   const putBackClickHandler = useCallback(() => {
   const putBackClickHandler = useCallback(() => {
     if (bookmarkedPage == null) return;
     if (bookmarkedPage == null) return;
 
 
     const { _id: pageId, path } = bookmarkedPage;
     const { _id: pageId, path } = bookmarkedPage;
-    const putBackedHandler = async() => {
+    const putBackedHandler = async () => {
       try {
       try {
         await unlink(path);
         await unlink(path);
         mutateAllPageInfo();
         mutateAllPageInfo();
@@ -155,36 +193,41 @@ export const BookmarkItem = (props: Props): JSX.Element => {
         router.push(`/${pageId}`);
         router.push(`/${pageId}`);
         fetchCurrentPage({ force: true });
         fetchCurrentPage({ force: true });
         toastSuccess(t('page_has_been_reverted', { path }));
         toastSuccess(t('page_has_been_reverted', { path }));
-      }
-      catch (err) {
+      } catch (err) {
         toastError(err);
         toastError(err);
       }
       }
     };
     };
     openPutBackPageModal({ pageId, path }, { onPutBacked: putBackedHandler });
     openPutBackPageModal({ pageId, path }, { onPutBacked: putBackedHandler });
-  }, [bookmarkedPage, openPutBackPageModal, bookmarkFolderTreeMutation, router, fetchCurrentPage, t]);
-
-  const {
-    pageTitle, formerPagePath, isFormerRoot, bookmarkItemId,
-  } = useMemo(() => {
-    const bookmarkItemId = `bookmark-item-${bookmarkedPageId}`;
+  }, [
+    bookmarkedPage,
+    openPutBackPageModal,
+    bookmarkFolderTreeMutation,
+    router,
+    fetchCurrentPage,
+    t,
+  ]);
+
+  const { pageTitle, formerPagePath, isFormerRoot, bookmarkItemId } =
+    useMemo(() => {
+      const bookmarkItemId = `bookmark-item-${bookmarkedPageId}`;
+
+      if (bookmarkedPagePath == null) {
+        return {
+          pageTitle: '',
+          formerPagePath: '',
+          isFormerRoot: false,
+          bookmarkItemId,
+        };
+      }
 
 
-    if (bookmarkedPagePath == null) {
+      const dPagePath = new DevidedPagePath(bookmarkedPagePath, false, true);
       return {
       return {
-        pageTitle: '',
-        formerPagePath: '',
-        isFormerRoot: false,
+        pageTitle: dPagePath.latter,
+        formerPagePath: dPagePath.former,
+        isFormerRoot: dPagePath.isFormerRoot,
         bookmarkItemId,
         bookmarkItemId,
       };
       };
-    }
-
-    const dPagePath = new DevidedPagePath(bookmarkedPagePath, false, true);
-    return {
-      pageTitle: dPagePath.latter,
-      formerPagePath: dPagePath.former,
-      isFormerRoot: dPagePath.isFormerRoot,
-      bookmarkItemId,
-    };
-  }, [bookmarkedPagePath, bookmarkedPageId]);
+    }, [bookmarkedPagePath, bookmarkedPageId]);
 
 
   if (bookmarkedPage == null) {
   if (bookmarkedPage == null) {
     return <></>;
     return <></>;
@@ -202,15 +245,21 @@ export const BookmarkItem = (props: Props): JSX.Element => {
         id={bookmarkItemId}
         id={bookmarkItemId}
         style={{ paddingLeft }}
         style={{ paddingLeft }}
       >
       >
-        { isRenameInputShown
-          ? (
-            <BookmarkItemRenameInput
-              value={nodePath.basename(bookmarkedPage.path ?? '')}
-              onSubmit={rename}
-              onCancel={() => { setRenameInputShown(false) }}
-            />
-          )
-          : <PageListItemS page={bookmarkedPage} pageTitle={pageTitle} isNarrowView />}
+        {isRenameInputShown ? (
+          <BookmarkItemRenameInput
+            value={nodePath.basename(bookmarkedPage.path ?? '')}
+            onSubmit={rename}
+            onCancel={() => {
+              setRenameInputShown(false);
+            }}
+          />
+        ) : (
+          <PageListItemS
+            page={bookmarkedPage}
+            pageTitle={pageTitle}
+            isNarrowView
+          />
+        )}
 
 
         <div className="grw-foldertree-control">
         <div className="grw-foldertree-control">
           <PageItemControl
           <PageItemControl
@@ -224,11 +273,21 @@ export const BookmarkItem = (props: Props): JSX.Element => {
             onClickRenameMenuItem={renameMenuItemClickHandler}
             onClickRenameMenuItem={renameMenuItemClickHandler}
             onClickDeleteMenuItem={deleteMenuItemClickHandler}
             onClickDeleteMenuItem={deleteMenuItemClickHandler}
             onClickRevertMenuItem={putBackClickHandler}
             onClickRevertMenuItem={putBackClickHandler}
-            additionalMenuItemOnTopRenderer={canMoveToRoot
-              ? () => <BookmarkMoveToRootBtn pageId={bookmarkedPage._id} onClickMoveToRootHandler={onClickMoveToRootHandler} />
-              : undefined}
+            additionalMenuItemOnTopRenderer={
+              canMoveToRoot
+                ? () => (
+                    <BookmarkMoveToRootBtn
+                      pageId={bookmarkedPage._id}
+                      onClickMoveToRootHandler={onClickMoveToRootHandler}
+                    />
+                  )
+                : undefined
+            }
           >
           >
-            <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover me-1">
+            <DropdownToggle
+              color="transparent"
+              className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover me-1"
+            >
               <span className="material-symbols-outlined p-1">more_vert</span>
               <span className="material-symbols-outlined p-1">more_vert</span>
             </DropdownToggle>
             </DropdownToggle>
           </PageItemControl>
           </PageItemControl>

+ 39 - 19
apps/app/src/client/components/Bookmarks/BookmarkItemRenameInput.tsx

@@ -1,19 +1,26 @@
 import type { ChangeEvent, JSX } from 'react';
 import type { ChangeEvent, JSX } from 'react';
 import { useCallback, useRef, useState } from 'react';
 import { useCallback, useRef, useState } from 'react';
-
 import { useRect } from '@growi/ui/dist/utils';
 import { useRect } from '@growi/ui/dist/utils';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import type { AutosizeInputProps } from 'react-input-autosize';
 import type { AutosizeInputProps } from 'react-input-autosize';
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
 
 
 import type { InputValidationResult } from '~/client/util/use-input-validator';
 import type { InputValidationResult } from '~/client/util/use-input-validator';
-import { ValidationTarget, useInputValidator } from '~/client/util/use-input-validator';
-
-import { AutosizeSubmittableInput, getAdjustedMaxWidthForAutosizeInput } from '../Common/SubmittableInput';
+import {
+  useInputValidator,
+  ValidationTarget,
+} from '~/client/util/use-input-validator';
+
+import {
+  AutosizeSubmittableInput,
+  getAdjustedMaxWidthForAutosizeInput,
+} from '../Common/SubmittableInput';
 import type { SubmittableInputProps } from '../Common/SubmittableInput/types';
 import type { SubmittableInputProps } from '../Common/SubmittableInput/types';
 
 
-
-type Props = Pick<SubmittableInputProps<AutosizeInputProps>, 'value' | 'onSubmit' | 'onCancel'>;
+type Props = Pick<
+  SubmittableInputProps<AutosizeInputProps>,
+  'value' | 'onSubmit' | 'onCancel'
+>;
 
 
 export const BookmarkItemRenameInput = (props: Props): JSX.Element => {
 export const BookmarkItemRenameInput = (props: Props): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -23,15 +30,18 @@ export const BookmarkItemRenameInput = (props: Props): JSX.Element => {
   const parentRef = useRef<HTMLDivElement>(null);
   const parentRef = useRef<HTMLDivElement>(null);
   const [parentRect] = useRect(parentRef);
   const [parentRect] = useRect(parentRef);
 
 
-  const [validationResult, setValidationResult] = useState<InputValidationResult>();
-
+  const [validationResult, setValidationResult] =
+    useState<InputValidationResult>();
 
 
   const inputValidator = useInputValidator(ValidationTarget.PAGE);
   const inputValidator = useInputValidator(ValidationTarget.PAGE);
 
 
-  const changeHandler = useCallback(async(e: ChangeEvent<HTMLInputElement>) => {
-    const validationResult = inputValidator(e.target.value);
-    setValidationResult(validationResult ?? undefined);
-  }, [inputValidator]);
+  const changeHandler = useCallback(
+    async (e: ChangeEvent<HTMLInputElement>) => {
+      const validationResult = inputValidator(e.target.value);
+      setValidationResult(validationResult ?? undefined);
+    },
+    [inputValidator],
+  );
   const changeHandlerDebounced = debounce(300, changeHandler);
   const changeHandlerDebounced = debounce(300, changeHandler);
 
 
   const cancelHandler = useCallback(() => {
   const cancelHandler = useCallback(() => {
@@ -41,9 +51,14 @@ export const BookmarkItemRenameInput = (props: Props): JSX.Element => {
 
 
   const isInvalid = validationResult != null;
   const isInvalid = validationResult != null;
 
 
-  const maxWidth = parentRect != null
-    ? getAdjustedMaxWidthForAutosizeInput(parentRect.width, 'md', validationResult != null ? false : undefined)
-    : undefined;
+  const maxWidth =
+    parentRect != null
+      ? getAdjustedMaxWidthForAutosizeInput(
+          parentRect.width,
+          'md',
+          validationResult != null ? false : undefined,
+        )
+      : undefined;
 
 
   return (
   return (
     <div className="flex-fill" ref={parentRef}>
     <div className="flex-fill" ref={parentRef}>
@@ -52,17 +67,22 @@ export const BookmarkItemRenameInput = (props: Props): JSX.Element => {
         inputClassName={`form-control ${isInvalid ? 'is-invalid' : ''}`}
         inputClassName={`form-control ${isInvalid ? 'is-invalid' : ''}`}
         inputStyle={{ maxWidth }}
         inputStyle={{ maxWidth }}
         placeholder={t('Input page name')}
         placeholder={t('Input page name')}
-        aria-describedby={isInvalid ? 'bookmark-item-rename-input-feedback' : undefined}
+        aria-describedby={
+          isInvalid ? 'bookmark-item-rename-input-feedback' : undefined
+        }
         autoFocus
         autoFocus
         onChange={changeHandlerDebounced}
         onChange={changeHandlerDebounced}
         onSubmit={onSubmit}
         onSubmit={onSubmit}
         onCancel={cancelHandler}
         onCancel={cancelHandler}
       />
       />
-      { isInvalid && (
-        <div id="bookmark-item-rename-input-feedback" className="invalid-feedback d-block my-1">
+      {isInvalid && (
+        <div
+          id="bookmark-item-rename-input-feedback"
+          className="invalid-feedback d-block my-1"
+        >
           {validationResult.message}
           {validationResult.message}
         </div>
         </div>
-      ) }
+      )}
     </div>
     </div>
   );
   );
 };
 };

+ 5 - 4
apps/app/src/client/components/Bookmarks/BookmarkMoveToRootBtn.tsx

@@ -1,11 +1,10 @@
 import React from 'react';
 import React from 'react';
-
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { DropdownItem } from 'reactstrap';
 import { DropdownItem } from 'reactstrap';
 
 
 export const BookmarkMoveToRootBtn: React.FC<{
 export const BookmarkMoveToRootBtn: React.FC<{
-  pageId: string
-  onClickMoveToRootHandler: (pageId: string) => Promise<void>
+  pageId: string;
+  onClickMoveToRootHandler: (pageId: string) => Promise<void>;
 }> = React.memo(({ pageId, onClickMoveToRootHandler }) => {
 }> = React.memo(({ pageId, onClickMoveToRootHandler }) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
@@ -14,7 +13,9 @@ export const BookmarkMoveToRootBtn: React.FC<{
       onClick={() => onClickMoveToRootHandler(pageId)}
       onClick={() => onClickMoveToRootHandler(pageId)}
       className="grw-page-control-dropdown-item"
       className="grw-page-control-dropdown-item"
     >
     >
-      <span className="material-symbols-outlined grw-page-control-dropdown-icon">bookmark</span>
+      <span className="material-symbols-outlined grw-page-control-dropdown-icon">
+        bookmark
+      </span>
       {t('bookmark_folder.move_to_root')}
       {t('bookmark_folder.move_to_root')}
     </DropdownItem>
     </DropdownItem>
   );
   );

+ 33 - 21
apps/app/src/client/components/Bookmarks/DragAndDropWrapper.tsx

@@ -1,33 +1,44 @@
-import type { ReactNode, JSX } from 'react';
-
+import type { JSX, ReactNode } from 'react';
 import { useDrag, useDrop } from 'react-dnd';
 import { useDrag, useDrop } from 'react-dnd';
 
 
 import type { DragItemDataType } from '~/interfaces/bookmark-info';
 import type { DragItemDataType } from '~/interfaces/bookmark-info';
 
 
 type DragAndDropWrapperProps = {
 type DragAndDropWrapperProps = {
-  item?: Partial<DragItemDataType>
-  type: string[]
-  children: ReactNode
-  useDragMode?: boolean
-  useDropMode?: boolean
-  onDropItem?:(item: DragItemDataType, type: string | null | symbol) => Promise<void>
-  isDropable?:(item: Partial<DragItemDataType>, type: string | null | symbol) => boolean
-}
+  item?: Partial<DragItemDataType>;
+  type: string[];
+  children: ReactNode;
+  useDragMode?: boolean;
+  useDropMode?: boolean;
+  onDropItem?: (
+    item: DragItemDataType,
+    type: string | null | symbol,
+  ) => Promise<void>;
+  isDropable?: (
+    item: Partial<DragItemDataType>,
+    type: string | null | symbol,
+  ) => boolean;
+};
 
 
-export const DragAndDropWrapper = (props: DragAndDropWrapperProps): JSX.Element => {
+export const DragAndDropWrapper = (
+  props: DragAndDropWrapperProps,
+): JSX.Element => {
   const {
   const {
-    item, children, useDragMode, useDropMode, type, onDropItem, isDropable,
+    item,
+    children,
+    useDragMode,
+    useDropMode,
+    type,
+    onDropItem,
+    isDropable,
   } = props;
   } = props;
 
 
-
   const acceptedTypes = type;
   const acceptedTypes = type;
   const sourcetype: string | symbol = type[0];
   const sourcetype: string | symbol = type[0];
 
 
-
   const [, dragRef] = useDrag({
   const [, dragRef] = useDrag({
     type: sourcetype,
     type: sourcetype,
     item,
     item,
-    collect: monitor => ({
+    collect: (monitor) => ({
       isDragging: monitor.isDragging(),
       isDragging: monitor.isDragging(),
       canDrag: monitor.canDrag(),
       canDrag: monitor.canDrag(),
     }),
     }),
@@ -48,7 +59,7 @@ export const DragAndDropWrapper = (props: DragAndDropWrapperProps): JSX.Element
       }
       }
       return false;
       return false;
     },
     },
-    collect: monitor => ({
+    collect: (monitor) => ({
       isOver: monitor.isOver({ shallow: true }) && monitor.canDrop(),
       isOver: monitor.isOver({ shallow: true }) && monitor.canDrop(),
     }),
     }),
   }));
   }));
@@ -57,17 +68,18 @@ export const DragAndDropWrapper = (props: DragAndDropWrapperProps): JSX.Element
     if (useDragMode && useDropMode) {
     if (useDragMode && useDropMode) {
       dragRef(c);
       dragRef(c);
       dropRef(c);
       dropRef(c);
-    }
-    else if (useDragMode) {
+    } else if (useDragMode) {
       dragRef(c);
       dragRef(c);
-    }
-    else if (useDropMode) {
+    } else if (useDropMode) {
       dropRef(c);
       dropRef(c);
     }
     }
   };
   };
 
 
   return (
   return (
-    <div ref={getCallback} className={`grw-drag-drop-container ${isOver ? 'grw-accept-drop-item' : ''}`}>
+    <div
+      ref={getCallback}
+      className={`grw-drag-drop-container ${isOver ? 'grw-accept-drop-item' : ''}`}
+    >
       {children}
       {children}
     </div>
     </div>
   );
   );

+ 51 - 31
apps/app/src/client/components/InAppNotification/InAppNotificationDropdown.tsx

@@ -1,15 +1,18 @@
-import React, {
-  useState, useEffect, useRef, type JSX,
-} from 'react';
-
+import React, { type JSX, useEffect, useRef, useState } from 'react';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { useRipple } from 'react-use-ripple';
 import { useRipple } from 'react-use-ripple';
 import {
 import {
-  Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
+  Dropdown,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
 import { useGlobalSocket } from '~/states/socket-io';
 import { useGlobalSocket } from '~/states/socket-io';
-import { useSWRxInAppNotifications, useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
+import {
+  useSWRxInAppNotificationStatus,
+  useSWRxInAppNotifications,
+} from '~/stores/in-app-notification';
 
 
 import InAppNotificationList from './InAppNotificationList';
 import InAppNotificationList from './InAppNotificationList';
 
 
@@ -20,11 +23,14 @@ export const InAppNotificationDropdown = (): JSX.Element => {
   const limit = 6;
   const limit = 6;
 
 
   const socket = useGlobalSocket();
   const socket = useGlobalSocket();
-  const { data: inAppNotificationData, mutate: mutateInAppNotificationData } = useSWRxInAppNotifications(
-    limit, undefined, undefined,
-    { revalidateOnFocus: isOpen },
-  );
-  const { data: inAppNotificationUnreadStatusCount, mutate: mutateInAppNotificationUnreadStatusCount } = useSWRxInAppNotificationStatus();
+  const { data: inAppNotificationData, mutate: mutateInAppNotificationData } =
+    useSWRxInAppNotifications(limit, undefined, undefined, {
+      revalidateOnFocus: isOpen,
+    });
+  const {
+    data: inAppNotificationUnreadStatusCount,
+    mutate: mutateInAppNotificationUnreadStatusCount,
+  } = useSWRxInAppNotificationStatus();
 
 
   // ripple
   // ripple
   const buttonRef = useRef(null);
   const buttonRef = useRef(null);
@@ -43,9 +49,12 @@ export const InAppNotificationDropdown = (): JSX.Element => {
     }
     }
   }, [mutateInAppNotificationUnreadStatusCount, socket]);
   }, [mutateInAppNotificationUnreadStatusCount, socket]);
 
 
-
-  const toggleDropdownHandler = async() => {
-    if (!isOpen && inAppNotificationUnreadStatusCount != null && inAppNotificationUnreadStatusCount > 0) {
+  const toggleDropdownHandler = async () => {
+    if (
+      !isOpen &&
+      inAppNotificationUnreadStatusCount != null &&
+      inAppNotificationUnreadStatusCount > 0
+    ) {
       mutateInAppNotificationUnreadStatusCount();
       mutateInAppNotificationUnreadStatusCount();
     }
     }
 
 
@@ -56,34 +65,45 @@ export const InAppNotificationDropdown = (): JSX.Element => {
     setIsOpen(newIsOpenState);
     setIsOpen(newIsOpenState);
   };
   };
 
 
-  let badge;
-  if (inAppNotificationUnreadStatusCount != null && inAppNotificationUnreadStatusCount > 0) {
-    badge = <span className="badge rounded-pill bg-danger grw-notification-badge">{inAppNotificationUnreadStatusCount}</span>;
-  }
-  else {
-    badge = '';
-  }
+  const badge =
+    inAppNotificationUnreadStatusCount != null &&
+    inAppNotificationUnreadStatusCount > 0 ? (
+      <span className="badge rounded-pill bg-danger grw-notification-badge">
+        {inAppNotificationUnreadStatusCount}
+      </span>
+    ) : null;
 
 
   return (
   return (
-    <Dropdown className="notification-wrapper grw-notification-dropdown" isOpen={isOpen} toggle={toggleDropdownHandler} direction="end">
+    <Dropdown
+      className="notification-wrapper grw-notification-dropdown"
+      isOpen={isOpen}
+      toggle={toggleDropdownHandler}
+      direction="end"
+    >
       <DropdownToggle className="px-3" color="primary" innerRef={buttonRef}>
       <DropdownToggle className="px-3" color="primary" innerRef={buttonRef}>
         <span className="material-symbols-outlined">notifications</span> {badge}
         <span className="material-symbols-outlined">notifications</span> {badge}
       </DropdownToggle>
       </DropdownToggle>
 
 
-      { isOpen && (
+      {isOpen && (
         <DropdownMenu end>
         <DropdownMenu end>
-          { inAppNotificationData != null && inAppNotificationData.docs.length === 0
-          // no items
-            ? <DropdownItem disabled>{t('in_app_notification.no_unread_messages')}</DropdownItem>
-          // render DropdownItem
-            : <InAppNotificationList inAppNotificationData={inAppNotificationData} />
-          }
+          {inAppNotificationData != null &&
+          inAppNotificationData.docs.length === 0 ? (
+            // no items
+            <DropdownItem disabled>
+              {t('in_app_notification.no_unread_messages')}
+            </DropdownItem>
+          ) : (
+            // render DropdownItem
+            <InAppNotificationList
+              inAppNotificationData={inAppNotificationData}
+            />
+          )}
           <DropdownItem divider />
           <DropdownItem divider />
           <DropdownItem tag="a" href="/me/all-in-app-notifications">
           <DropdownItem tag="a" href="/me/all-in-app-notifications">
-            { t('in_app_notification.see_all') }
+            {t('in_app_notification.see_all')}
           </DropdownItem>
           </DropdownItem>
         </DropdownMenu>
         </DropdownMenu>
-      ) }
+      )}
     </Dropdown>
     </Dropdown>
   );
   );
 };
 };

+ 14 - 12
apps/app/src/client/components/InAppNotification/InAppNotificationElm.tsx

@@ -1,6 +1,5 @@
 import type { FC, JSX } from 'react';
 import type { FC, JSX } from 'react';
 import React from 'react';
 import React from 'react';
-
 import type { HasObjectId } from '@growi/core';
 import type { HasObjectId } from '@growi/core';
 import { UserPicture } from '@growi/ui/dist/components';
 import { UserPicture } from '@growi/ui/dist/components';
 
 
@@ -12,12 +11,11 @@ import { useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
 import { useModelNotification } from './ModelNotification';
 import { useModelNotification } from './ModelNotification';
 
 
 interface Props {
 interface Props {
-  notification: IInAppNotification & HasObjectId
-  onUnopenedNotificationOpend?: () => void,
+  notification: IInAppNotification & HasObjectId;
+  onUnopenedNotificationOpend?: () => void;
 }
 }
 
 
 const InAppNotificationElm: FC<Props> = (props: Props) => {
 const InAppNotificationElm: FC<Props> = (props: Props) => {
-
   const { notification, onUnopenedNotificationOpend } = props;
   const { notification, onUnopenedNotificationOpend } = props;
 
 
   const modelNotificationUtils = useModelNotification(notification);
   const modelNotificationUtils = useModelNotification(notification);
@@ -32,7 +30,9 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
     return <></>;
     return <></>;
   }
   }
 
 
-  const clickHandler = async(notification: IInAppNotification & HasObjectId): Promise<void> => {
+  const clickHandler = async (
+    notification: IInAppNotification & HasObjectId,
+  ): Promise<void> => {
     if (notification.status === InAppNotificationStatuses.STATUS_UNOPENED) {
     if (notification.status === InAppNotificationStatuses.STATUS_UNOPENED) {
       // set notification status "OPEND"
       // set notification status "OPEND"
       await apiv3Post('/in-app-notification/open', { id: notification._id });
       await apiv3Post('/in-app-notification/open', { id: notification._id });
@@ -65,24 +65,26 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
   };
   };
 
 
   return (
   return (
-    <div className="list-group-item list-group-item-action" style={{ cursor: 'pointer' }}>
+    <div
+      className="list-group-item list-group-item-action"
+      style={{ cursor: 'pointer' }}
+    >
       <a
       <a
         href={isDisabled ? undefined : clickLink}
         href={isDisabled ? undefined : clickLink}
         onClick={() => clickHandler(notification)}
         onClick={() => clickHandler(notification)}
       >
       >
         <div className="d-flex align-items-center">
         <div className="d-flex align-items-center">
           <span
           <span
-            className={`${notification.status === InAppNotificationStatuses.STATUS_UNOPENED
-              ? 'grw-unopend-notification'
-              : 'ms-2'
+            className={`${
+              notification.status === InAppNotificationStatuses.STATUS_UNOPENED
+                ? 'grw-unopend-notification'
+                : 'ms-2'
             } rounded-circle me-3`}
             } rounded-circle me-3`}
-          >
-          </span>
+          ></span>
 
 
           {renderActionUserPictures()}
           {renderActionUserPictures()}
 
 
           <Notification />
           <Notification />
-
         </div>
         </div>
       </a>
       </a>
     </div>
     </div>

+ 8 - 9
apps/app/src/client/components/InAppNotification/InAppNotificationList.tsx

@@ -1,18 +1,18 @@
 import type { FC } from 'react';
 import type { FC } from 'react';
 import React from 'react';
 import React from 'react';
-
 import type { HasObjectId } from '@growi/core';
 import type { HasObjectId } from '@growi/core';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 
 
-import type { IInAppNotification, PaginateResult } from '~/interfaces/in-app-notification';
-
+import type {
+  IInAppNotification,
+  PaginateResult,
+} from '~/interfaces/in-app-notification';
 
 
 import InAppNotificationElm from './InAppNotificationElm';
 import InAppNotificationElm from './InAppNotificationElm';
 
 
-
 type Props = {
 type Props = {
-  inAppNotificationData?: PaginateResult<IInAppNotification>,
-  onUnopenedNotificationOpend?: () => void,
+  inAppNotificationData?: PaginateResult<IInAppNotification>;
+  onUnopenedNotificationOpend?: () => void;
 };
 };
 
 
 const InAppNotificationList: FC<Props> = (props: Props) => {
 const InAppNotificationList: FC<Props> = (props: Props) => {
@@ -32,7 +32,7 @@ const InAppNotificationList: FC<Props> = (props: Props) => {
 
 
   return (
   return (
     <div className="list-group">
     <div className="list-group">
-      { notifications.map((notification: IInAppNotification & HasObjectId) => {
+      {notifications.map((notification: IInAppNotification & HasObjectId) => {
         return (
         return (
           <InAppNotificationElm
           <InAppNotificationElm
             key={notification._id}
             key={notification._id}
@@ -40,10 +40,9 @@ const InAppNotificationList: FC<Props> = (props: Props) => {
             onUnopenedNotificationOpend={onUnopenedNotificationOpend}
             onUnopenedNotificationOpend={onUnopenedNotificationOpend}
           />
           />
         );
         );
-      }) }
+      })}
     </div>
     </div>
   );
   );
 };
 };
 
 
-
 export default InAppNotificationList;
 export default InAppNotificationList;

+ 105 - 80
apps/app/src/client/components/InAppNotification/InAppNotificationPage.tsx

@@ -1,6 +1,5 @@
 import type { FC } from 'react';
 import type { FC } from 'react';
 import React, { useState } from 'react';
 import React, { useState } from 'react';
-
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useAtomValue } from 'jotai';
 import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
@@ -8,117 +7,143 @@ import { useTranslation } from 'next-i18next';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
 import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
 import { showPageLimitationXLAtom } from '~/states/server-configurations';
 import { showPageLimitationXLAtom } from '~/states/server-configurations';
-import { useSWRxInAppNotifications, useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
+import {
+  useSWRxInAppNotificationStatus,
+  useSWRxInAppNotifications,
+} from '~/stores/in-app-notification';
 
 
 import CustomNavAndContents from '../CustomNavigation/CustomNavAndContents';
 import CustomNavAndContents from '../CustomNavigation/CustomNavAndContents';
 import PaginationWrapper from '../PaginationWrapper';
 import PaginationWrapper from '../PaginationWrapper';
-
 import InAppNotificationList from './InAppNotificationList';
 import InAppNotificationList from './InAppNotificationList';
 
 
-export const InAppNotificationPage: FC = () => {
+type InAppNotificationCategoryByStatusProps = {
+  status?: InAppNotificationStatuses;
+};
+
+const EmptyIcon: FC = () => {
+  return null;
+};
+
+const InAppNotificationCategoryByStatus: FC<
+  InAppNotificationCategoryByStatusProps
+> = ({ status }) => {
   const { t } = useTranslation('commons');
   const { t } = useTranslation('commons');
 
 
   const showPageLimitationXL = useAtomValue(showPageLimitationXLAtom);
   const showPageLimitationXL = useAtomValue(showPageLimitationXLAtom);
-
   const limit = showPageLimitationXL != null ? showPageLimitationXL : 20;
   const limit = showPageLimitationXL != null ? showPageLimitationXL : 20;
 
 
-  const InAppNotificationCategoryByStatus = (status?: InAppNotificationStatuses) => {
-    const [activePage, setActivePage] = useState(1);
-    const offset = (activePage - 1) * limit;
-
-    let categoryStatus;
+  const [activePage, setActivePage] = useState(1);
+  const offset = (activePage - 1) * limit;
 
 
-    switch (status) {
-      case InAppNotificationStatuses.STATUS_UNOPENED:
-        categoryStatus = InAppNotificationStatuses.STATUS_UNOPENED;
-        break;
-      default:
-    }
+  const categoryStatus =
+    status === InAppNotificationStatuses.STATUS_UNOPENED
+      ? InAppNotificationStatuses.STATUS_UNOPENED
+      : undefined;
 
 
-    const { data: notificationData, mutate: mutateNotificationData } = useSWRxInAppNotifications(limit, offset, categoryStatus);
-    const { mutate: mutateAllNotificationData } = useSWRxInAppNotifications(limit, offset, undefined);
-    const { mutate: mutateNotificationCount } = useSWRxInAppNotificationStatus();
+  const { data: notificationData, mutate: mutateNotificationData } =
+    useSWRxInAppNotifications(limit, offset, categoryStatus);
+  const { mutate: mutateAllNotificationData } = useSWRxInAppNotifications(
+    limit,
+    offset,
+    undefined,
+  );
+  const { mutate: mutateNotificationCount } = useSWRxInAppNotificationStatus();
 
 
-    const setAllNotificationPageNumber = (selectedPageNumber): void => {
-      setActivePage(selectedPageNumber);
-    };
+  const setAllNotificationPageNumber = (selectedPageNumber: number): void => {
+    setActivePage(selectedPageNumber);
+  };
 
 
+  if (notificationData == null) {
+    return (
+      <div className="wiki" data-testid="grw-in-app-notification-page-spinner">
+        <div className="text-muted text-center">
+          <LoadingSpinner className="me-1 fs-3" />
+        </div>
+      </div>
+    );
+  }
+
+  const updateUnopendNotificationStatusesToOpened = async () => {
+    await apiv3Put('/in-app-notification/all-statuses-open');
+    // mutate notification statuses in 'UNREAD' Category
+    mutateNotificationData();
+    // mutate notification statuses in 'ALL' Category
+    mutateAllNotificationData();
+    mutateNotificationCount();
+  };
 
 
-    if (notificationData == null) {
-      return (
-        <div className="wiki" data-testid="grw-in-app-notification-page-spinner">
-          <div className="text-muted text-center">
-            <LoadingSpinner className="me-1 fs-3" />
+  return (
+    <>
+      {status === InAppNotificationStatuses.STATUS_UNOPENED &&
+        notificationData.totalDocs > 0 && (
+          <div className="mb-2 d-flex justify-content-end">
+            <button
+              type="button"
+              className="btn btn-outline-primary"
+              onClick={updateUnopendNotificationStatusesToOpened}
+            >
+              {t('in_app_notification.mark_all_as_read')}
+            </button>
           </div>
           </div>
+        )}
+      {notificationData != null && notificationData.docs.length === 0 ? (
+        // no items
+        t('in_app_notification.no_unread_messages')
+      ) : (
+        // render list-group
+        <InAppNotificationList inAppNotificationData={notificationData} />
+      )}
+
+      {notificationData.totalDocs > 0 && (
+        <div className="mt-4">
+          <PaginationWrapper
+            activePage={activePage}
+            changePage={setAllNotificationPageNumber}
+            totalItemsCount={notificationData.totalDocs}
+            pagingLimit={notificationData.limit}
+            align="center"
+            size="sm"
+          />
         </div>
         </div>
-      );
-    }
+      )}
+    </>
+  );
+};
 
 
-    const updateUnopendNotificationStatusesToOpened = async() => {
-      await apiv3Put('/in-app-notification/all-statuses-open');
-      // mutate notification statuses in 'UNREAD' Category
-      mutateNotificationData();
-      // mutate notification statuses in 'ALL' Category
-      mutateAllNotificationData();
-      mutateNotificationCount();
-    };
+const InAppNotificationAllTabContent: FC = () => {
+  return <InAppNotificationCategoryByStatus />;
+};
 
 
+const InAppNotificationUnreadTabContent: FC = () => {
+  return (
+    <InAppNotificationCategoryByStatus
+      status={InAppNotificationStatuses.STATUS_UNOPENED}
+    />
+  );
+};
 
 
-    return (
-      <>
-        {(status === InAppNotificationStatuses.STATUS_UNOPENED && notificationData.totalDocs > 0)
-      && (
-        <div className="mb-2 d-flex justify-content-end">
-          <button
-            type="button"
-            className="btn btn-outline-primary"
-            onClick={updateUnopendNotificationStatusesToOpened}
-          >
-            {t('in_app_notification.mark_all_as_read')}
-          </button>
-        </div>
-      )}
-        { notificationData != null && notificationData.docs.length === 0
-          // no items
-          ? t('in_app_notification.no_unread_messages')
-          // render list-group
-          : (
-            <InAppNotificationList inAppNotificationData={notificationData} />
-          )
-        }
-
-        {notificationData.totalDocs > 0 && (
-          <div className="mt-4">
-            <PaginationWrapper
-              activePage={activePage}
-              changePage={setAllNotificationPageNumber}
-              totalItemsCount={notificationData.totalDocs}
-              pagingLimit={notificationData.limit}
-              align="center"
-              size="sm"
-            />
-          </div>
-        ) }
-      </>
-    );
-  };
+export const InAppNotificationPage: FC = () => {
+  const { t } = useTranslation('commons');
 
 
   const navTabMapping = {
   const navTabMapping = {
     user_infomation: {
     user_infomation: {
-      Icon: () => <></>,
-      Content: () => InAppNotificationCategoryByStatus(),
+      Icon: EmptyIcon,
+      Content: InAppNotificationAllTabContent,
       i18n: t('in_app_notification.all'),
       i18n: t('in_app_notification.all'),
     },
     },
     external_accounts: {
     external_accounts: {
-      Icon: () => <></>,
-      Content: () => InAppNotificationCategoryByStatus(InAppNotificationStatuses.STATUS_UNOPENED),
+      Icon: EmptyIcon,
+      Content: InAppNotificationUnreadTabContent,
       i18n: t('in_app_notification.unopend'),
       i18n: t('in_app_notification.unopend'),
     },
     },
   };
   };
 
 
   return (
   return (
     <div data-testid="grw-in-app-notification-page">
     <div data-testid="grw-in-app-notification-page">
-      <CustomNavAndContents navTabMapping={navTabMapping} tabContentClasses={['mt-4']} />
+      <CustomNavAndContents
+        navTabMapping={navTabMapping}
+        tabContentClasses={['mt-4']}
+      />
     </div>
     </div>
   );
   );
 };
 };

+ 7 - 9
apps/app/src/client/components/InAppNotification/ModelNotification/ModelNotification.tsx

@@ -1,6 +1,5 @@
 import type { FC, JSX } from 'react';
 import type { FC, JSX } from 'react';
 import React from 'react';
 import React from 'react';
-
 import type { HasObjectId } from '@growi/core';
 import type { HasObjectId } from '@growi/core';
 import { PagePathLabel } from '@growi/ui/dist/components';
 import { PagePathLabel } from '@growi/ui/dist/components';
 
 
@@ -11,12 +10,12 @@ import FormattedDistanceDate from '../../FormattedDistanceDate';
 import styles from './ModelNotification.module.scss';
 import styles from './ModelNotification.module.scss';
 
 
 type Props = {
 type Props = {
-  notification: IInAppNotification & HasObjectId
-  actionMsg: string
-  actionIcon: string
-  actionUsers: string
-  hideActionUsers?: boolean
-  subMsg?: JSX.Element
+  notification: IInAppNotification & HasObjectId;
+  actionMsg: string;
+  actionIcon: string;
+  actionUsers: string;
+  hideActionUsers?: boolean;
+  subMsg?: JSX.Element;
 };
 };
 
 
 export const ModelNotification: FC<Props> = ({
 export const ModelNotification: FC<Props> = ({
@@ -27,7 +26,6 @@ export const ModelNotification: FC<Props> = ({
   hideActionUsers = false,
   hideActionUsers = false,
   subMsg,
   subMsg,
 }: Props) => {
 }: Props) => {
-
   return (
   return (
     <div className={`${styles['modal-notification']} p-2 overflow-hidden`}>
     <div className={`${styles['modal-notification']} p-2 overflow-hidden`}>
       <div className="text-truncate page-title">
       <div className="text-truncate page-title">
@@ -35,7 +33,7 @@ export const ModelNotification: FC<Props> = ({
         {` ${actionMsg}`}
         {` ${actionMsg}`}
         <PagePathLabel path={notification.parsedSnapshot?.path ?? ''} />
         <PagePathLabel path={notification.parsedSnapshot?.path ?? ''} />
       </div>
       </div>
-      { subMsg }
+      {subMsg}
       <span className="material-symbols-outlined me-2">{actionIcon}</span>
       <span className="material-symbols-outlined me-2">{actionIcon}</span>
       <FormattedDistanceDate
       <FormattedDistanceDate
         id={notification._id}
         id={notification._id}

+ 42 - 20
apps/app/src/client/components/InAppNotification/ModelNotification/PageBulkExportJobModelNotification.tsx

@@ -1,6 +1,5 @@
 import React from 'react';
 import React from 'react';
-
-import { isPopulated, type HasObjectId } from '@growi/core';
+import { type HasObjectId, isPopulated } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
 import type { IPageBulkExportJobHasId } from '~/features/page-bulk-export/interfaces/page-bulk-export';
 import type { IPageBulkExportJobHasId } from '~/features/page-bulk-export/interfaces/page-bulk-export';
@@ -8,21 +7,25 @@ import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
 import * as pageBulkExportJobSerializers from '~/models/serializers/in-app-notification-snapshot/page-bulk-export-job';
 import * as pageBulkExportJobSerializers from '~/models/serializers/in-app-notification-snapshot/page-bulk-export-job';
 
 
+import type { ModelNotificationUtils } from '.';
 import { ModelNotification } from './ModelNotification';
 import { ModelNotification } from './ModelNotification';
 import { useActionMsgAndIconForModelNotification } from './useActionAndMsg';
 import { useActionMsgAndIconForModelNotification } from './useActionAndMsg';
 
 
-import type { ModelNotificationUtils } from '.';
-
-
-export const usePageBulkExportJobModelNotification = (notification: IInAppNotification & HasObjectId): ModelNotificationUtils | null => {
-
+export const usePageBulkExportJobModelNotification = (
+  notification: IInAppNotification & HasObjectId,
+): ModelNotificationUtils | null => {
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const { actionMsg, actionIcon } = useActionMsgAndIconForModelNotification(notification);
+  const { actionMsg, actionIcon } =
+    useActionMsgAndIconForModelNotification(notification);
 
 
   const isPageBulkExportJobModelNotification = (
   const isPageBulkExportJobModelNotification = (
-      notification: IInAppNotification & HasObjectId,
-  ): notification is IInAppNotification<IPageBulkExportJobHasId> & HasObjectId => {
-    return notification.targetModel === SupportedTargetModel.MODEL_PAGE_BULK_EXPORT_JOB;
+    notification: IInAppNotification & HasObjectId,
+  ): notification is IInAppNotification<IPageBulkExportJobHasId> &
+    HasObjectId => {
+    return (
+      notification.targetModel ===
+      SupportedTargetModel.MODEL_PAGE_BULK_EXPORT_JOB
+    );
   };
   };
 
 
   if (!isPageBulkExportJobModelNotification(notification)) {
   if (!isPageBulkExportJobModelNotification(notification)) {
@@ -31,14 +34,31 @@ export const usePageBulkExportJobModelNotification = (notification: IInAppNotifi
 
 
   const actionUsers = notification.user.username;
   const actionUsers = notification.user.username;
 
 
-  notification.parsedSnapshot = pageBulkExportJobSerializers.parseSnapshot(notification.snapshot);
+  notification.parsedSnapshot = pageBulkExportJobSerializers.parseSnapshot(
+    notification.snapshot,
+  );
 
 
   const getSubMsg = (): JSX.Element => {
   const getSubMsg = (): JSX.Element => {
-    if (notification.action === SupportedAction.ACTION_PAGE_BULK_EXPORT_COMPLETED && notification.target == null) {
-      return <div className="text-danger"><small>{t('page_export.bulk_export_download_expired')}</small></div>;
+    if (
+      notification.action ===
+        SupportedAction.ACTION_PAGE_BULK_EXPORT_COMPLETED &&
+      notification.target == null
+    ) {
+      return (
+        <div className="text-danger">
+          <small>{t('page_export.bulk_export_download_expired')}</small>
+        </div>
+      );
     }
     }
-    if (notification.action === SupportedAction.ACTION_PAGE_BULK_EXPORT_JOB_EXPIRED) {
-      return <div className="text-danger"><small>{t('page_export.bulk_export_job_expired')}</small></div>;
+    if (
+      notification.action ===
+      SupportedAction.ACTION_PAGE_BULK_EXPORT_JOB_EXPIRED
+    ) {
+      return (
+        <div className="text-danger">
+          <small>{t('page_export.bulk_export_job_expired')}</small>
+        </div>
+      );
     }
     }
     return <></>;
     return <></>;
   };
   };
@@ -56,14 +76,16 @@ export const usePageBulkExportJobModelNotification = (notification: IInAppNotifi
     );
     );
   };
   };
 
 
-  const clickLink = (notification.action === SupportedAction.ACTION_PAGE_BULK_EXPORT_COMPLETED
-    && notification.target?.attachment != null && isPopulated(notification.target?.attachment))
-    ? notification.target.attachment.downloadPathProxied : undefined;
+  const clickLink =
+    notification.action === SupportedAction.ACTION_PAGE_BULK_EXPORT_COMPLETED &&
+    notification.target?.attachment != null &&
+    isPopulated(notification.target?.attachment)
+      ? notification.target.attachment.downloadPathProxied
+      : undefined;
 
 
   return {
   return {
     Notification,
     Notification,
     clickLink,
     clickLink,
     isDisabled: notification.target == null,
     isDisabled: notification.target == null,
   };
   };
-
 };
 };

+ 15 - 14
apps/app/src/client/components/InAppNotification/ModelNotification/PageModelNotification.tsx

@@ -1,21 +1,21 @@
 import React, { useCallback } from 'react';
 import React, { useCallback } from 'react';
-
-import type { IPage, HasObjectId } from '@growi/core';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
+import type { HasObjectId, IPage } from '@growi/core';
 
 
 import { SupportedTargetModel } from '~/interfaces/activity';
 import { SupportedTargetModel } from '~/interfaces/activity';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
 import * as pageSerializers from '~/models/serializers/in-app-notification-snapshot/page';
 import * as pageSerializers from '~/models/serializers/in-app-notification-snapshot/page';
 
 
+import type { ModelNotificationUtils } from '.';
 import { ModelNotification } from './ModelNotification';
 import { ModelNotification } from './ModelNotification';
 import { useActionMsgAndIconForModelNotification } from './useActionAndMsg';
 import { useActionMsgAndIconForModelNotification } from './useActionAndMsg';
 
 
-import type { ModelNotificationUtils } from '.';
-
-export const usePageModelNotification = (notification: IInAppNotification & HasObjectId): ModelNotificationUtils | null => {
-
+export const usePageModelNotification = (
+  notification: IInAppNotification & HasObjectId,
+): ModelNotificationUtils | null => {
   const router = useRouter();
   const router = useRouter();
-  const { actionMsg, actionIcon } = useActionMsgAndIconForModelNotification(notification);
+  const { actionMsg, actionIcon } =
+    useActionMsgAndIconForModelNotification(notification);
 
 
   const getActionUsers = useCallback(() => {
   const getActionUsers = useCallback(() => {
     const latestActionUsers = notification.actionUsers.slice(0, 3);
     const latestActionUsers = notification.actionUsers.slice(0, 3);
@@ -27,18 +27,18 @@ export const usePageModelNotification = (notification: IInAppNotification & HasO
     const latestUsersCount = latestUsers.length;
     const latestUsersCount = latestUsers.length;
     if (latestUsersCount === 1) {
     if (latestUsersCount === 1) {
       actionedUsers = latestUsers[0];
       actionedUsers = latestUsers[0];
-    }
-    else if (notification.actionUsers.length >= 4) {
+    } else if (notification.actionUsers.length >= 4) {
       actionedUsers = `${latestUsers.slice(0, 2).join(', ')} and ${notification.actionUsers.length - 2} others`;
       actionedUsers = `${latestUsers.slice(0, 2).join(', ')} and ${notification.actionUsers.length - 2} others`;
-    }
-    else {
+    } else {
       actionedUsers = latestUsers.join(', ');
       actionedUsers = latestUsers.join(', ');
     }
     }
 
 
     return actionedUsers;
     return actionedUsers;
   }, [notification.actionUsers]);
   }, [notification.actionUsers]);
 
 
-  const isPageModelNotification = (notification: IInAppNotification & HasObjectId): notification is IInAppNotification<IPage> & HasObjectId => {
+  const isPageModelNotification = (
+    notification: IInAppNotification & HasObjectId,
+  ): notification is IInAppNotification<IPage> & HasObjectId => {
     return notification.targetModel === SupportedTargetModel.MODEL_PAGE;
     return notification.targetModel === SupportedTargetModel.MODEL_PAGE;
   };
   };
 
 
@@ -48,7 +48,9 @@ export const usePageModelNotification = (notification: IInAppNotification & HasO
 
 
   const actionUsers = getActionUsers();
   const actionUsers = getActionUsers();
 
 
-  notification.parsedSnapshot = pageSerializers.parseSnapshot(notification.snapshot);
+  notification.parsedSnapshot = pageSerializers.parseSnapshot(
+    notification.snapshot,
+  );
 
 
   const Notification = () => {
   const Notification = () => {
     return (
     return (
@@ -75,5 +77,4 @@ export const usePageModelNotification = (notification: IInAppNotification & HasO
     Notification,
     Notification,
     publishOpen,
     publishOpen,
   };
   };
-
 };
 };

+ 10 - 10
apps/app/src/client/components/InAppNotification/ModelNotification/UserModelNotification.tsx

@@ -1,23 +1,24 @@
 import React from 'react';
 import React from 'react';
-
-import type { IUser, HasObjectId } from '@growi/core';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
+import type { HasObjectId, IUser } from '@growi/core';
 
 
 import { SupportedTargetModel } from '~/interfaces/activity';
 import { SupportedTargetModel } from '~/interfaces/activity';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
 
 
+import type { ModelNotificationUtils } from '.';
 import { ModelNotification } from './ModelNotification';
 import { ModelNotification } from './ModelNotification';
 import { useActionMsgAndIconForModelNotification } from './useActionAndMsg';
 import { useActionMsgAndIconForModelNotification } from './useActionAndMsg';
 
 
-import type { ModelNotificationUtils } from '.';
-
-
-export const useUserModelNotification = (notification: IInAppNotification & HasObjectId): ModelNotificationUtils | null => {
-
-  const { actionMsg, actionIcon } = useActionMsgAndIconForModelNotification(notification);
+export const useUserModelNotification = (
+  notification: IInAppNotification & HasObjectId,
+): ModelNotificationUtils | null => {
+  const { actionMsg, actionIcon } =
+    useActionMsgAndIconForModelNotification(notification);
   const router = useRouter();
   const router = useRouter();
 
 
-  const isUserModelNotification = (notification: IInAppNotification & HasObjectId): notification is IInAppNotification<IUser> & HasObjectId => {
+  const isUserModelNotification = (
+    notification: IInAppNotification & HasObjectId,
+  ): notification is IInAppNotification<IUser> & HasObjectId => {
     return notification.targetModel === SupportedTargetModel.MODEL_USER;
     return notification.targetModel === SupportedTargetModel.MODEL_USER;
   };
   };
 
 
@@ -46,5 +47,4 @@ export const useUserModelNotification = (notification: IInAppNotification & HasO
     Notification,
     Notification,
     publishOpen,
     publishOpen,
   };
   };
-
 };
 };

+ 13 - 11
apps/app/src/client/components/InAppNotification/ModelNotification/index.tsx

@@ -1,31 +1,33 @@
 import type { FC } from 'react';
 import type { FC } from 'react';
-
 import type { HasObjectId } from '@growi/core';
 import type { HasObjectId } from '@growi/core';
 
 
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
 
 
-
 import { usePageBulkExportJobModelNotification } from './PageBulkExportJobModelNotification';
 import { usePageBulkExportJobModelNotification } from './PageBulkExportJobModelNotification';
 import { usePageModelNotification } from './PageModelNotification';
 import { usePageModelNotification } from './PageModelNotification';
 import { useUserModelNotification } from './UserModelNotification';
 import { useUserModelNotification } from './UserModelNotification';
 
 
 export interface ModelNotificationUtils {
 export interface ModelNotificationUtils {
-  Notification: FC
-  publishOpen?: () => void
-  clickLink?: string
+  Notification: FC;
+  publishOpen?: () => void;
+  clickLink?: string;
   // Whether actions from clicking notification is disabled or not.
   // Whether actions from clicking notification is disabled or not.
   // User can still open the notification when true.
   // User can still open the notification when true.
-  isDisabled?: boolean
+  isDisabled?: boolean;
 }
 }
 
 
-export const useModelNotification = (notification: IInAppNotification & HasObjectId): ModelNotificationUtils | null => {
-
+export const useModelNotification = (
+  notification: IInAppNotification & HasObjectId,
+): ModelNotificationUtils | null => {
   const pageModelNotificationUtils = usePageModelNotification(notification);
   const pageModelNotificationUtils = usePageModelNotification(notification);
   const userModelNotificationUtils = useUserModelNotification(notification);
   const userModelNotificationUtils = useUserModelNotification(notification);
-  const pageBulkExportResultModelNotificationUtils = usePageBulkExportJobModelNotification(notification);
-
-  const modelNotificationUtils = pageModelNotificationUtils ?? userModelNotificationUtils ?? pageBulkExportResultModelNotificationUtils;
+  const pageBulkExportResultModelNotificationUtils =
+    usePageBulkExportJobModelNotification(notification);
 
 
+  const modelNotificationUtils =
+    pageModelNotificationUtils ??
+    userModelNotificationUtils ??
+    pageBulkExportResultModelNotificationUtils;
 
 
   return modelNotificationUtils;
   return modelNotificationUtils;
 };
 };

+ 6 - 4
apps/app/src/client/components/InAppNotification/ModelNotification/useActionAndMsg.ts

@@ -4,11 +4,13 @@ import { SupportedAction } from '~/interfaces/activity';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
 
 
 export type ActionMsgAndIconType = {
 export type ActionMsgAndIconType = {
-  actionMsg: string
-  actionIcon: string
-}
+  actionMsg: string;
+  actionIcon: string;
+};
 
 
-export const useActionMsgAndIconForModelNotification = (notification: IInAppNotification & HasObjectId): ActionMsgAndIconType => {
+export const useActionMsgAndIconForModelNotification = (
+  notification: IInAppNotification & HasObjectId,
+): ActionMsgAndIconType => {
   const actionType: string = notification.action;
   const actionType: string = notification.action;
   let actionMsg: string;
   let actionMsg: string;
   let actionIcon: string;
   let actionIcon: string;

+ 130 - 112
apps/app/src/client/components/Me/AccessTokenForm.tsx

@@ -1,5 +1,4 @@
 import React from 'react';
 import React from 'react';
-
 import type { Scope } from '@growi/core/dist/interfaces';
 import type { Scope } from '@growi/core/dist/interfaces';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { useForm } from 'react-hook-form';
 import { useForm } from 'react-hook-form';
@@ -12,132 +11,151 @@ const MAX_DESCRIPTION_LENGTH = 200;
 
 
 type AccessTokenFormProps = {
 type AccessTokenFormProps = {
   submitHandler: (info: IAccessTokenInfo) => Promise<void>;
   submitHandler: (info: IAccessTokenInfo) => Promise<void>;
-}
+};
 
 
 type FormInputs = {
 type FormInputs = {
   expiredAt: string;
   expiredAt: string;
   description: string;
   description: string;
   scopes: Scope[];
   scopes: Scope[];
-}
-
-export const AccessTokenForm = React.memo((props: AccessTokenFormProps): JSX.Element => {
-  const { submitHandler } = props;
-  const { t } = useTranslation();
-
-  const defaultExpiredAt = new Date();
-  defaultExpiredAt.setMonth(defaultExpiredAt.getMonth() + 1);
-  const defaultExpiredAtStr = defaultExpiredAt.toISOString().split('T')[0];
-  const todayStr = new Date().toISOString().split('T')[0];
-
-  const {
-    register,
-    handleSubmit,
-    formState: { errors, isValid },
-    watch,
-  } = useForm<FormInputs>({
-    defaultValues: {
-      expiredAt: defaultExpiredAtStr,
-      description: '',
-      scopes: [],
-    },
-  });
-
-  const onSubmit = (data: FormInputs) => {
-    const expiredAtDate = new Date(data.expiredAt);
-    expiredAtDate.setHours(23, 59, 59, 999);
-    const scopes: Scope[] = data.scopes ? data.scopes : [];
-
-    submitHandler({
-      expiredAt: expiredAtDate,
-      description: data.description,
-      scopes,
+};
+
+export const AccessTokenForm = React.memo(
+  (props: AccessTokenFormProps): JSX.Element => {
+    const { submitHandler } = props;
+    const { t } = useTranslation();
+
+    const defaultExpiredAt = new Date();
+    defaultExpiredAt.setMonth(defaultExpiredAt.getMonth() + 1);
+    const defaultExpiredAtStr = defaultExpiredAt.toISOString().split('T')[0];
+    const todayStr = new Date().toISOString().split('T')[0];
+
+    const {
+      register,
+      handleSubmit,
+      formState: { errors, isValid },
+      watch,
+    } = useForm<FormInputs>({
+      defaultValues: {
+        expiredAt: defaultExpiredAtStr,
+        description: '',
+        scopes: [],
+      },
     });
     });
-  };
 
 
-  return (
-    <div className="card mt-3 mb-4">
-      <div className="card-header">{t('page_me_access_token.form.title')}</div>
-      <div className="card-body">
-        <form onSubmit={handleSubmit(onSubmit)}>
-          <div className="mb-3">
-            <label htmlFor="expiredAt" className="form-label">{t('page_me_access_token.expiredAt')}</label>
-            <div className="row">
-              <div className="col-16 col-sm-4 col-md-4 col-lg-3">
-                <div className="input-group">
-                  <input
-                    type="date"
-                    className={`form-control ${errors.expiredAt ? 'is-invalid' : ''}`}
-                    data-testid="grw-accesstoken-input-expiredAt"
-                    min={todayStr}
-                    {...register('expiredAt', {
-                      required: t('input_validation.message.required', { param: t('page_me_access_token.expiredAt') }),
-                    })}
-                  />
-                </div>
-                {errors.expiredAt && (
-                  <div className="invalid-feedback d-block">
-                    {errors.expiredAt.message}
+    const onSubmit = (data: FormInputs) => {
+      const expiredAtDate = new Date(data.expiredAt);
+      expiredAtDate.setHours(23, 59, 59, 999);
+      const scopes: Scope[] = data.scopes ? data.scopes : [];
+
+      submitHandler({
+        expiredAt: expiredAtDate,
+        description: data.description,
+        scopes,
+      });
+    };
+
+    return (
+      <div className="card mt-3 mb-4">
+        <div className="card-header">
+          {t('page_me_access_token.form.title')}
+        </div>
+        <div className="card-body">
+          <form onSubmit={handleSubmit(onSubmit)}>
+            <div className="mb-3">
+              <label htmlFor="expiredAt" className="form-label">
+                {t('page_me_access_token.expiredAt')}
+              </label>
+              <div className="row">
+                <div className="col-16 col-sm-4 col-md-4 col-lg-3">
+                  <div className="input-group">
+                    <input
+                      type="date"
+                      className={`form-control ${errors.expiredAt ? 'is-invalid' : ''}`}
+                      data-testid="grw-accesstoken-input-expiredAt"
+                      min={todayStr}
+                      {...register('expiredAt', {
+                        required: t('input_validation.message.required', {
+                          param: t('page_me_access_token.expiredAt'),
+                        }),
+                      })}
+                    />
                   </div>
                   </div>
-                )}
+                  {errors.expiredAt && (
+                    <div className="invalid-feedback d-block">
+                      {errors.expiredAt.message}
+                    </div>
+                  )}
+                </div>
+              </div>
+              <div className="form-text">
+                {t('page_me_access_token.form.expiredAt_desc')}
               </div>
               </div>
             </div>
             </div>
-            <div className="form-text">{t('page_me_access_token.form.expiredAt_desc')}</div>
-          </div>
 
 
-          <div className="mb-3">
-            <label htmlFor="description" className="form-label">{t('page_me_access_token.description')}</label>
-            <textarea
-              className={`form-control ${errors.description ? 'is-invalid' : ''}`}
-              rows={3}
-              data-testid="grw-accesstoken-textarea-description"
-              {...register('description', {
-                required: t('input_validation.message.required', { param: t('page_me_access_token.description') }),
-                maxLength: {
-                  value: MAX_DESCRIPTION_LENGTH,
-                  message: t('page_me_access_token.form.description_max_length', { length: MAX_DESCRIPTION_LENGTH }),
-                },
-              })}
-            />
-            {errors.description && (
-              <div className="invalid-feedback">
-                {errors.description.message}
+            <div className="mb-3">
+              <label htmlFor="description" className="form-label">
+                {t('page_me_access_token.description')}
+              </label>
+              <textarea
+                className={`form-control ${errors.description ? 'is-invalid' : ''}`}
+                rows={3}
+                data-testid="grw-accesstoken-textarea-description"
+                {...register('description', {
+                  required: t('input_validation.message.required', {
+                    param: t('page_me_access_token.description'),
+                  }),
+                  maxLength: {
+                    value: MAX_DESCRIPTION_LENGTH,
+                    message: t(
+                      'page_me_access_token.form.description_max_length',
+                      { length: MAX_DESCRIPTION_LENGTH },
+                    ),
+                  },
+                })}
+              />
+              {errors.description && (
+                <div className="invalid-feedback">
+                  {errors.description.message}
+                </div>
+              )}
+              <div className="form-text">
+                {t('page_me_access_token.form.description_desc')}
               </div>
               </div>
-            )}
-            <div className="form-text">{t('page_me_access_token.form.description_desc')}</div>
-          </div>
+            </div>
 
 
-          <div className="mb-3">
-            <label htmlFor="scopes" className="form-label">
-              {t('page_me_access_token.scope')}
-            </label>
-            <AccessTokenScopeSelect
-              selectedScopes={watch('scopes')}
-              register={register('scopes', {
-                required: t('input_validation.message.required', { param: t('page_me_access_token.scope') }),
-              })}
-            />
-            {errors.scopes && (
-              <div className="invalid-feedback">
-                {errors.scopes.message}
+            <div className="mb-3">
+              <label htmlFor="scopes" className="form-label">
+                {t('page_me_access_token.scope')}
+              </label>
+              <AccessTokenScopeSelect
+                selectedScopes={watch('scopes')}
+                register={register('scopes', {
+                  required: t('input_validation.message.required', {
+                    param: t('page_me_access_token.scope'),
+                  }),
+                })}
+              />
+              {errors.scopes && (
+                <div className="invalid-feedback">{errors.scopes.message}</div>
+              )}
+
+              <div className="form-text mb-2">
+                {t('page_me_access_token.form.scope_desc')}
               </div>
               </div>
-            )}
-
-            <div className="form-text mb-2">
-              {t('page_me_access_token.form.scope_desc')}
             </div>
             </div>
-          </div>
 
 
-          <button
-            type="submit"
-            className="btn btn-primary"
-            data-testid="grw-accesstoken-create-button"
-            disabled={!isValid}
-          >
-            {t('page_me_access_token.create_token')}
-          </button>
-        </form>
+            <button
+              type="submit"
+              className="btn btn-primary"
+              data-testid="grw-accesstoken-create-button"
+              disabled={!isValid}
+            >
+              {t('page_me_access_token.create_token')}
+            </button>
+          </form>
+        </div>
       </div>
       </div>
-    </div>
-  );
-});
+    );
+  },
+);
 AccessTokenForm.displayName = 'AccessTokenForm';
 AccessTokenForm.displayName = 'AccessTokenForm';

+ 76 - 68
apps/app/src/client/components/Me/AccessTokenList.tsx

@@ -1,62 +1,56 @@
 import React, { useState } from 'react';
 import React, { useState } from 'react';
-
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter, Button,
-} from 'reactstrap';
+import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 
 import type { IResGetAccessToken } from '~/interfaces/access-token';
 import type { IResGetAccessToken } from '~/interfaces/access-token';
 
 
 type AccessTokenListProps = {
 type AccessTokenListProps = {
   accessTokens: IResGetAccessToken[];
   accessTokens: IResGetAccessToken[];
   deleteHandler?: (tokenId: string) => void;
   deleteHandler?: (tokenId: string) => void;
-}
-export const AccessTokenList = React.memo((props: AccessTokenListProps): JSX.Element => {
-
+};
+export const AccessTokenList = React.memo(
+  (props: AccessTokenListProps): JSX.Element => {
+    const { t } = useTranslation();
+    const { accessTokens, deleteHandler } = props;
+    const [tokenToDelete, setTokenToDelete] = useState<string | null>(null);
 
 
-  const { t } = useTranslation();
-  const { accessTokens, deleteHandler } = props;
-  const [tokenToDelete, setTokenToDelete] = useState<string | null>(null);
+    const handleDeleteClick = (tokenId: string) => {
+      setTokenToDelete(tokenId);
+    };
 
 
-  const handleDeleteClick = (tokenId: string) => {
-    setTokenToDelete(tokenId);
-  };
+    const handleConfirmDelete = () => {
+      if (tokenToDelete != null && deleteHandler != null) {
+        deleteHandler(tokenToDelete);
+        setTokenToDelete(null);
+      }
+    };
 
 
-  const handleConfirmDelete = () => {
-    if (tokenToDelete != null && deleteHandler != null) {
-      deleteHandler(tokenToDelete);
+    const toggleModal = () => {
       setTokenToDelete(null);
       setTokenToDelete(null);
-    }
-  };
-
-  const toggleModal = () => {
-    setTokenToDelete(null);
-  };
+    };
 
 
-  return (
-    <>
-      <div className="table">
-        <table className="table table-bordered">
-          <thead>
-            <tr>
-              <th>{t('page_me_access_token.description')}</th>
-              <th>{t('page_me_access_token.expiredAt')}</th>
-              <th>{t('page_me_access_token.scope')}</th>
-              <th>{t('page_me_access_token.action')}</th>
-            </tr>
-          </thead>
-          <tbody>
-            {(accessTokens.length === 0)
-              ? (
+    return (
+      <>
+        <div className="table">
+          <table className="table table-bordered">
+            <thead>
+              <tr>
+                <th>{t('page_me_access_token.description')}</th>
+                <th>{t('page_me_access_token.expiredAt')}</th>
+                <th>{t('page_me_access_token.scope')}</th>
+                <th>{t('page_me_access_token.action')}</th>
+              </tr>
+            </thead>
+            <tbody>
+              {accessTokens.length === 0 ? (
                 <tr>
                 <tr>
                   <td colSpan={4} className="text-center">
                   <td colSpan={4} className="text-center">
                     {t('page_me_access_token.no_tokens_found')}
                     {t('page_me_access_token.no_tokens_found')}
                   </td>
                   </td>
                 </tr>
                 </tr>
-              )
-              : (
-                <>{
-                  accessTokens.map(token => (
+              ) : (
+                <>
+                  {accessTokens.map((token) => (
                     <tr key={token._id}>
                     <tr key={token._id}>
                       <td className="text-break">{token.description}</td>
                       <td className="text-break">{token.description}</td>
                       <td>{token.expiredAt.toString().split('T')[0]}</td>
                       <td>{token.expiredAt.toString().split('T')[0]}</td>
@@ -72,34 +66,48 @@ export const AccessTokenList = React.memo((props: AccessTokenListProps): JSX.Ele
                         </button>
                         </button>
                       </td>
                       </td>
                     </tr>
                     </tr>
-                  ))
-                }
+                  ))}
                 </>
                 </>
               )}
               )}
-          </tbody>
-        </table>
-      </div>
+            </tbody>
+          </table>
+        </div>
 
 
-      {/* Confirmation Modal using Reactstrap */}
-      <Modal isOpen={tokenToDelete !== null} toggle={toggleModal} centered>
-        <ModalHeader tag="h4" toggle={toggleModal} className="bg-danger text-white">
-          <span className="material-symbols-outlined me-1">warning</span>
-          {t('Warning')}
-        </ModalHeader>
-        <ModalBody>
-          <p>{t('page_me_access_token.modal.message')}</p>
-          <p className="text-danger fw-bold">{t('page_me_access_token.modal.alert')}</p>
-        </ModalBody>
-        <ModalFooter>
-          <Button color="secondary" onClick={toggleModal} data-testid="grw-accesstoken-cancel-button-in-modal">
-            {t('Cancel')}
-          </Button>
-          <Button color="danger" onClick={handleConfirmDelete} data-testid="grw-accesstoken-delete-button-in-modal">
-            {t('page_me_access_token.modal.delete_token')}
-          </Button>
-        </ModalFooter>
-      </Modal>
-    </>
-  );
-});
+        {/* Confirmation Modal using Reactstrap */}
+        <Modal isOpen={tokenToDelete !== null} toggle={toggleModal} centered>
+          <ModalHeader
+            tag="h4"
+            toggle={toggleModal}
+            className="bg-danger text-white"
+          >
+            <span className="material-symbols-outlined me-1">warning</span>
+            {t('Warning')}
+          </ModalHeader>
+          <ModalBody>
+            <p>{t('page_me_access_token.modal.message')}</p>
+            <p className="text-danger fw-bold">
+              {t('page_me_access_token.modal.alert')}
+            </p>
+          </ModalBody>
+          <ModalFooter>
+            <Button
+              color="secondary"
+              onClick={toggleModal}
+              data-testid="grw-accesstoken-cancel-button-in-modal"
+            >
+              {t('Cancel')}
+            </Button>
+            <Button
+              color="danger"
+              onClick={handleConfirmDelete}
+              data-testid="grw-accesstoken-delete-button-in-modal"
+            >
+              {t('page_me_access_token.modal.delete_token')}
+            </Button>
+          </ModalFooter>
+        </Modal>
+      </>
+    );
+  },
+);
 AccessTokenList.displayName = 'AccessTokenList';
 AccessTokenList.displayName = 'AccessTokenList';

+ 16 - 8
apps/app/src/client/components/Me/AccessTokenScopeList.tsx

@@ -1,12 +1,10 @@
-import React from 'react';
-
+import type React from 'react';
 import type { Scope } from '@growi/core/dist/interfaces';
 import type { Scope } from '@growi/core/dist/interfaces';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import type { UseFormRegisterReturn } from 'react-hook-form';
 import type { UseFormRegisterReturn } from 'react-hook-form';
 
 
 import { useDeviceLargerThanMd } from '~/states/ui/device';
 import { useDeviceLargerThanMd } from '~/states/ui/device';
 
 
-
 import styles from './AccessTokenScopeList.module.scss';
 import styles from './AccessTokenScopeList.module.scss';
 
 
 const moduleClass = styles['access-token-scope-list'] ?? '';
 const moduleClass = styles['access-token-scope-list'] ?? '';
@@ -18,7 +16,7 @@ interface scopeObject {
 interface AccessTokenScopeListProps {
 interface AccessTokenScopeListProps {
   scopeObject: scopeObject;
   scopeObject: scopeObject;
   register: UseFormRegisterReturn<'scopes'>;
   register: UseFormRegisterReturn<'scopes'>;
-  disabledScopes: Set<Scope>
+  disabledScopes: Set<Scope>;
   level?: number;
   level?: number;
 }
 }
 
 
@@ -31,7 +29,6 @@ export const AccessTokenScopeList: React.FC<AccessTokenScopeListProps> = ({
   disabledScopes,
   disabledScopes,
   level = 1,
   level = 1,
 }) => {
 }) => {
-
   const [isDeviceLargerThanMd] = useDeviceLargerThanMd();
   const [isDeviceLargerThanMd] = useDeviceLargerThanMd();
 
 
   // Convert object into an array to determine "first vs. non-first" elements
   // Convert object into an array to determine "first vs. non-first" elements
@@ -49,7 +46,11 @@ export const AccessTokenScopeList: React.FC<AccessTokenScopeListProps> = ({
               {showHr && <hr className="my-1" />}
               {showHr && <hr className="my-1" />}
               <div className="my-1 row">
               <div className="my-1 row">
                 <div className="col-md-5 ">
                 <div className="col-md-5 ">
-                  <label className={`form-check-label fw-bold indentation indentation-level-${level}`}>{scopeKey}</label>
+                  <span
+                    className={`form-check-label fw-bold indentation indentation-level-${level}`}
+                  >
+                    {scopeKey}
+                  </span>
                 </div>
                 </div>
               </div>
               </div>
 
 
@@ -76,11 +77,18 @@ export const AccessTokenScopeList: React.FC<AccessTokenScopeListProps> = ({
                 value={scopeValue as string}
                 value={scopeValue as string}
                 {...register}
                 {...register}
               />
               />
-              <label className="form-check-label ms-2" htmlFor={scopeValue as string}>
+              <label
+                className="form-check-label ms-2"
+                htmlFor={scopeValue as string}
+              >
                 {scopeKey}
                 {scopeKey}
               </label>
               </label>
             </div>
             </div>
-            <div className={`col form-text ${isDeviceLargerThanMd ? '' : 'text-end'}`}>{t(`accesstoken_scopes_desc.${scopeKey.replace(/:/g, '.')}`)}</div>
+            <div
+              className={`col form-text ${isDeviceLargerThanMd ? '' : 'text-end'}`}
+            >
+              {t(`accesstoken_scopes_desc.${scopeKey.replace(/:/g, '.')}`)}
+            </div>
           </div>
           </div>
         );
         );
       })}
       })}

+ 20 - 6
apps/app/src/client/components/Me/AccessTokenScopeSelect.tsx

@@ -1,10 +1,14 @@
-import React, { useEffect, useState, useMemo } from 'react';
-
+import type React from 'react';
+import { useEffect, useMemo, useState } from 'react';
 import type { Scope } from '@growi/core/dist/interfaces';
 import type { Scope } from '@growi/core/dist/interfaces';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import type { UseFormRegisterReturn } from 'react-hook-form';
 import type { UseFormRegisterReturn } from 'react-hook-form';
 
 
-import { extractScopes, getDisabledScopes, parseScopes } from '~/client/util/scope-util';
+import {
+  extractScopes,
+  getDisabledScopes,
+  parseScopes,
+} from '~/client/util/scope-util';
 import { useIsAdmin } from '~/states/context';
 import { useIsAdmin } from '~/states/context';
 
 
 import { AccessTokenScopeList } from './AccessTokenScopeList';
 import { AccessTokenScopeList } from './AccessTokenScopeList';
@@ -21,11 +25,17 @@ type AccessTokenScopeSelectProps = {
 /**
 /**
  * Displays a list of permissions in a recursive, nested checkbox interface.
  * Displays a list of permissions in a recursive, nested checkbox interface.
  */
  */
-export const AccessTokenScopeSelect: React.FC<AccessTokenScopeSelectProps> = ({ register, selectedScopes }) => {
+export const AccessTokenScopeSelect: React.FC<AccessTokenScopeSelectProps> = ({
+  register,
+  selectedScopes,
+}) => {
   const [disabledScopes, setDisabledScopes] = useState<Set<Scope>>(new Set());
   const [disabledScopes, setDisabledScopes] = useState<Set<Scope>>(new Set());
   const isAdmin = useIsAdmin();
   const isAdmin = useIsAdmin();
 
 
-  const ScopesMap = useMemo(() => parseScopes({ scopes: SCOPE, isAdmin }), [isAdmin]);
+  const ScopesMap = useMemo(
+    () => parseScopes({ scopes: SCOPE, isAdmin }),
+    [isAdmin],
+  );
   const extractedScopes = useMemo(() => extractScopes(ScopesMap), [ScopesMap]);
   const extractedScopes = useMemo(() => extractScopes(ScopesMap), [ScopesMap]);
 
 
   useEffect(() => {
   useEffect(() => {
@@ -35,7 +45,11 @@ export const AccessTokenScopeSelect: React.FC<AccessTokenScopeSelectProps> = ({
 
 
   return (
   return (
     <div className="border rounded">
     <div className="border rounded">
-      <AccessTokenScopeList scopeObject={ScopesMap} register={register} disabledScopes={disabledScopes} />
+      <AccessTokenScopeList
+        scopeObject={ScopesMap}
+        register={register}
+        disabledScopes={disabledScopes}
+      />
     </div>
     </div>
   );
   );
 };
 };

+ 107 - 82
apps/app/src/client/components/Me/AccessTokenSettings.tsx

@@ -1,122 +1,147 @@
 import React, { useCallback } from 'react';
 import React, { useCallback } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import CopyToClipboard from 'react-copy-to-clipboard';
 import CopyToClipboard from 'react-copy-to-clipboard';
 
 
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import type { IAccessTokenInfo } from '~/interfaces/access-token';
 import type { IAccessTokenInfo } from '~/interfaces/access-token';
 import { useSWRxAccessToken } from '~/stores/personal-settings';
 import { useSWRxAccessToken } from '~/stores/personal-settings';
 
 
 import { AccessTokenForm } from './AccessTokenForm';
 import { AccessTokenForm } from './AccessTokenForm';
 import { AccessTokenList } from './AccessTokenList';
 import { AccessTokenList } from './AccessTokenList';
 
 
+const NewTokenDisplay = React.memo(
+  ({
+    newToken,
+    closeNewTokenDisplay,
+  }: {
+    newToken?: string;
+    closeNewTokenDisplay: () => void;
+  }): JSX.Element => {
+    const { t } = useTranslation();
+
+    // Handle successful copy
+    const handleCopySuccess = useCallback(() => {
+      toastSuccess(t('page_me_access_token.new_token.copy_to_clipboard'));
+    }, [t]);
+
+    if (newToken == null) {
+      return <></>;
+    }
 
 
-const NewTokenDisplay = React.memo(({ newToken, closeNewTokenDisplay }: { newToken?: string, closeNewTokenDisplay: () => void }): JSX.Element => {
-
-  const { t } = useTranslation();
-
-  // Handle successful copy
-  const handleCopySuccess = useCallback(() => {
-    toastSuccess(t('page_me_access_token.new_token.copy_to_clipboard'));
-  }, [t]);
-
-  if (newToken == null) {
-    return <></>;
-  }
-
-  return (
-    <div className="alert alert-success mb-4" role="alert" data-testid="grw-accesstoken-new-token-display">
-      <div className="d-flex justify-content-between align-items-center mb-2">
-        <h5 className="mb-0">
-          {t('page_me_access_token.new_token.title')}
-        </h5>
-        <button
-          type="button"
-          className="btn-close"
-          onClick={closeNewTokenDisplay}
-          aria-label="Close"
-        >
-        </button>
-      </div>
-
-      <p className="fw-bold mb-2">{t('page_me_access_token.new_token.message')}</p>
-
-      <div className="input-group mb-2">
-        <input
-          type="text"
-          className="form-control font-monospace"
-          value={newToken}
-          readOnly
-          data-vrt-blackout
-        />
-        <CopyToClipboard text={newToken} onCopy={handleCopySuccess}>
+    return (
+      <div
+        className="alert alert-success mb-4"
+        role="alert"
+        data-testid="grw-accesstoken-new-token-display"
+      >
+        <div className="d-flex justify-content-between align-items-center mb-2">
+          <h5 className="mb-0">{t('page_me_access_token.new_token.title')}</h5>
           <button
           <button
-            className="btn btn-outline-secondary"
             type="button"
             type="button"
-          >
-            <span className="material-symbols-outlined">content_copy</span>
-          </button>
-        </CopyToClipboard>
+            className="btn-close"
+            onClick={closeNewTokenDisplay}
+            aria-label="Close"
+          ></button>
+        </div>
+
+        <p className="fw-bold mb-2">
+          {t('page_me_access_token.new_token.message')}
+        </p>
+
+        <div className="input-group mb-2">
+          <input
+            type="text"
+            className="form-control font-monospace"
+            value={newToken}
+            readOnly
+            data-vrt-blackout
+          />
+          <CopyToClipboard text={newToken} onCopy={handleCopySuccess}>
+            <button className="btn btn-outline-secondary" type="button">
+              <span className="material-symbols-outlined">content_copy</span>
+            </button>
+          </CopyToClipboard>
+        </div>
       </div>
       </div>
-    </div>
-  );
-});
+    );
+  },
+);
 
 
 export const AccessTokenSettings = React.memo((): JSX.Element => {
 export const AccessTokenSettings = React.memo((): JSX.Element => {
-
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const [isFormOpen, setIsFormOpen] = React.useState<boolean>(false);
   const [isFormOpen, setIsFormOpen] = React.useState<boolean>(false);
   const toggleFormOpen = useCallback(() => {
   const toggleFormOpen = useCallback(() => {
-    setIsFormOpen(prev => !prev);
+    setIsFormOpen((prev) => !prev);
   }, []);
   }, []);
 
 
   const [newToken, setNewToken] = React.useState<string | undefined>(undefined);
   const [newToken, setNewToken] = React.useState<string | undefined>(undefined);
 
 
   const {
   const {
-    data: accessTokens, mutate, generateAccessToken, deleteAccessToken,
+    data: accessTokens,
+    mutate,
+    generateAccessToken,
+    deleteAccessToken,
   } = useSWRxAccessToken();
   } = useSWRxAccessToken();
 
 
   const closeNewTokenDisplay = useCallback(() => {
   const closeNewTokenDisplay = useCallback(() => {
     setNewToken(undefined);
     setNewToken(undefined);
   }, []);
   }, []);
 
 
-  const submitHandler = useCallback(async(info: IAccessTokenInfo) => {
-    try {
-      const result = await generateAccessToken(info);
-      mutate();
-      setIsFormOpen(false);
-
-      // Store the newly generated token to display to the user
-      if (result?.token) {
-        setNewToken(result.token);
+  const submitHandler = useCallback(
+    async (info: IAccessTokenInfo) => {
+      try {
+        const result = await generateAccessToken(info);
+        mutate();
+        setIsFormOpen(false);
+
+        // Store the newly generated token to display to the user
+        if (result?.token) {
+          setNewToken(result.token);
+        }
+
+        toastSuccess(
+          t('toaster.add_succeeded', {
+            target: t('page_me_access_token.access_token'),
+            ns: 'commons',
+          }),
+        );
+      } catch (err) {
+        toastError(err);
       }
       }
+    },
+    [t, generateAccessToken, mutate],
+  );
 
 
-      toastSuccess(t('toaster.add_succeeded', { target: t('page_me_access_token.access_token'), ns: 'commons' }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [t, generateAccessToken, mutate, setIsFormOpen]);
-
-  const deleteHandler = useCallback(async(tokenId: string) => {
-    try {
-      await deleteAccessToken(tokenId);
-      mutate();
-      toastSuccess(t('toaster.delete_succeeded', { target: t('page_me_access_token.access_token'), ns: 'commons' }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [deleteAccessToken, mutate, t]);
+  const deleteHandler = useCallback(
+    async (tokenId: string) => {
+      try {
+        await deleteAccessToken(tokenId);
+        mutate();
+        toastSuccess(
+          t('toaster.delete_succeeded', {
+            target: t('page_me_access_token.access_token'),
+            ns: 'commons',
+          }),
+        );
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [deleteAccessToken, mutate, t],
+  );
 
 
   return (
   return (
     <>
     <>
-
       <div className="container p-0">
       <div className="container p-0">
-
-        <NewTokenDisplay newToken={newToken} closeNewTokenDisplay={closeNewTokenDisplay} />
-        <AccessTokenList accessTokens={accessTokens ?? []} deleteHandler={deleteHandler} />
+        <NewTokenDisplay
+          newToken={newToken}
+          closeNewTokenDisplay={closeNewTokenDisplay}
+        />
+        <AccessTokenList
+          accessTokens={accessTokens ?? []}
+          deleteHandler={deleteHandler}
+        />
 
 
         <button
         <button
           className="btn btn-outline-secondary d-block mx-auto px-5"
           className="btn btn-outline-secondary d-block mx-auto px-5"

+ 8 - 6
apps/app/src/client/components/Me/ApiSettings.tsx

@@ -1,5 +1,4 @@
 import React from 'react';
 import React from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import { useCurrentUser } from '~/states/global';
 import { useCurrentUser } from '~/states/global';
@@ -7,24 +6,27 @@ import { useCurrentUser } from '~/states/global';
 import { AccessTokenSettings } from './AccessTokenSettings';
 import { AccessTokenSettings } from './AccessTokenSettings';
 import { ApiTokenSettings } from './ApiTokenSettings';
 import { ApiTokenSettings } from './ApiTokenSettings';
 
 
-
 const ApiSettings = React.memo((): JSX.Element => {
 const ApiSettings = React.memo((): JSX.Element => {
-
   const { t } = useTranslation();
   const { t } = useTranslation();
   const currentUser = useCurrentUser();
   const currentUser = useCurrentUser();
 
 
-  const shouldHideAccessTokenSettings = currentUser == null || !currentUser?.readOnly;
+  const shouldHideAccessTokenSettings =
+    currentUser == null || !currentUser?.readOnly;
 
 
   return (
   return (
     <>
     <>
       <div className="mt-4">
       <div className="mt-4">
-        <h2 className="border-bottom pb-2 my-4 fs-4">{ t('API Token Settings') }</h2>
+        <h2 className="border-bottom pb-2 my-4 fs-4">
+          {t('API Token Settings')}
+        </h2>
         <ApiTokenSettings />
         <ApiTokenSettings />
       </div>
       </div>
 
 
       {shouldHideAccessTokenSettings && (
       {shouldHideAccessTokenSettings && (
         <div className="mt-4">
         <div className="mt-4">
-          <h2 className="border-bottom pb-2 my-4 fs-4">{ t('Access Token Settings') }</h2>
+          <h2 className="border-bottom pb-2 my-4 fs-4">
+            {t('Access Token Settings')}
+          </h2>
           <AccessTokenSettings />
           <AccessTokenSettings />
         </div>
         </div>
       )}
       )}

+ 34 - 39
apps/app/src/client/components/Me/ApiTokenSettings.tsx

@@ -1,67 +1,64 @@
 import React, { useCallback } from 'react';
 import React, { useCallback } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
-import {
-  apiv3Put,
-} from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { useSWRxPersonalSettings } from '~/stores/personal-settings';
 import { useSWRxPersonalSettings } from '~/stores/personal-settings';
 
 
-
 export const ApiTokenSettings = React.memo((): JSX.Element => {
 export const ApiTokenSettings = React.memo((): JSX.Element => {
-
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const { data: personalSettingsData, mutate: mutateDatabaseData } = useSWRxPersonalSettings();
-
-  const submitHandler = useCallback(async() => {
+  const { data: personalSettingsData, mutate: mutateDatabaseData } =
+    useSWRxPersonalSettings();
 
 
+  const submitHandler = useCallback(async () => {
     try {
     try {
       await apiv3Put('/personal-setting/api-token');
       await apiv3Put('/personal-setting/api-token');
       mutateDatabaseData();
       mutateDatabaseData();
 
 
-      toastSuccess(t('toaster.update_successed', { target: t('page_me_apitoken.api_token'), ns: 'commons' }));
-    }
-    catch (err) {
+      toastSuccess(
+        t('toaster.update_successed', {
+          target: t('page_me_apitoken.api_token'),
+          ns: 'commons',
+        }),
+      );
+    } catch (err) {
       toastError(err);
       toastError(err);
     }
     }
-
   }, [mutateDatabaseData, t]);
   }, [mutateDatabaseData, t]);
 
 
   return (
   return (
     <>
     <>
       <div className="row mb-3">
       <div className="row mb-3">
-        <label htmlFor="apiToken" className="col-md-3 text-md-end col-form-label">{t('Current API Token')}</label>
+        <label
+          htmlFor="apiToken"
+          className="col-md-3 text-md-end col-form-label"
+        >
+          {t('Current API Token')}
+        </label>
         <div className="col-md-6">
         <div className="col-md-6">
-          {personalSettingsData?.apiToken != null
-            ? (
-              <input
-                data-testid="grw-api-settings-input"
-                data-vrt-blackout
-                className="form-control"
-                type="text"
-                name="apiToken"
-                value={personalSettingsData.apiToken}
-                readOnly
-              />
-            )
-            : (
-              <p>
-                { t('page_me_apitoken.notice.apitoken_issued') }
-              </p>
-            )}
+          {personalSettingsData?.apiToken != null ? (
+            <input
+              data-testid="grw-api-settings-input"
+              data-vrt-blackout
+              className="form-control"
+              type="text"
+              name="apiToken"
+              value={personalSettingsData.apiToken}
+              readOnly
+            />
+          ) : (
+            <p>{t('page_me_apitoken.notice.apitoken_issued')}</p>
+          )}
         </div>
         </div>
       </div>
       </div>
 
 
-
       <div className="row">
       <div className="row">
         <div className="offset-lg-2 col-lg-7">
         <div className="offset-lg-2 col-lg-7">
-
           <p className="alert alert-warning">
           <p className="alert alert-warning">
-            { t('page_me_apitoken.notice.update_token1') }<br />
-            { t('page_me_apitoken.notice.update_token2') }
+            {t('page_me_apitoken.notice.update_token1')}
+            <br />
+            {t('page_me_apitoken.notice.update_token2')}
           </p>
           </p>
-
         </div>
         </div>
       </div>
       </div>
 
 
@@ -77,8 +74,6 @@ export const ApiTokenSettings = React.memo((): JSX.Element => {
           </button>
           </button>
         </div>
         </div>
       </div>
       </div>
-
     </>
     </>
-
   );
   );
 });
 });

+ 59 - 35
apps/app/src/client/components/Me/AssociateModal.tsx

@@ -1,26 +1,28 @@
-import React, { useState, useCallback, type JSX } from 'react';
-
+import React, { type JSX, useCallback, useState } from 'react';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import {
 import {
   Modal,
   Modal,
-  ModalHeader,
   ModalBody,
   ModalBody,
   ModalFooter,
   ModalFooter,
+  ModalHeader,
   Nav,
   Nav,
   NavLink,
   NavLink,
   TabContent,
   TabContent,
   TabPane,
   TabPane,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useAssociateLdapAccount, useSWRxPersonalExternalAccounts } from '~/stores/personal-settings';
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import {
+  useAssociateLdapAccount,
+  useSWRxPersonalExternalAccounts,
+} from '~/stores/personal-settings';
 
 
 import { LdapAuthTest } from '../Admin/Security/LdapAuthTest';
 import { LdapAuthTest } from '../Admin/Security/LdapAuthTest';
 
 
 type Props = {
 type Props = {
-  isOpen: boolean,
-  onClose: () => void,
-}
+  isOpen: boolean;
+  onClose: () => void;
+};
 
 
 /**
 /**
  * AssociateModalSubstance - Presentation component (heavy logic, rendered only when isOpen)
  * AssociateModalSubstance - Presentation component (heavy logic, rendered only when isOpen)
@@ -29,10 +31,13 @@ type AssociateModalSubstanceProps = {
   onClose: () => void;
   onClose: () => void;
 };
 };
 
 
-const AssociateModalSubstance = (props: AssociateModalSubstanceProps): JSX.Element => {
+const AssociateModalSubstance = (
+  props: AssociateModalSubstanceProps,
+): JSX.Element => {
   const { onClose } = props;
   const { onClose } = props;
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const { mutate: mutatePersonalExternalAccounts } = useSWRxPersonalExternalAccounts();
+  const { mutate: mutatePersonalExternalAccounts } =
+    useSWRxPersonalExternalAccounts();
   const { trigger: associateLdapAccount } = useAssociateLdapAccount();
   const { trigger: associateLdapAccount } = useAssociateLdapAccount();
 
 
   const [activeTab, setActiveTab] = useState(1);
   const [activeTab, setActiveTab] = useState(1);
@@ -45,29 +50,41 @@ const AssociateModalSubstance = (props: AssociateModalSubstanceProps): JSX.Eleme
     setPassword('');
     setPassword('');
   }, [onClose]);
   }, [onClose]);
 
 
-  const clickAddLdapAccountHandler = useCallback(async() => {
+  const clickAddLdapAccountHandler = useCallback(async () => {
     try {
     try {
       await associateLdapAccount({ username, password });
       await associateLdapAccount({ username, password });
       mutatePersonalExternalAccounts();
       mutatePersonalExternalAccounts();
 
 
       closeModalHandler();
       closeModalHandler();
       toastSuccess(t('security_settings.updated_general_security_setting'));
       toastSuccess(t('security_settings.updated_general_security_setting'));
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
       toastError(err);
     }
     }
-  }, [associateLdapAccount, closeModalHandler, mutatePersonalExternalAccounts, password, t, username]);
+  }, [
+    associateLdapAccount,
+    closeModalHandler,
+    mutatePersonalExternalAccounts,
+    password,
+    t,
+    username,
+  ]);
 
 
   const setTabToLdap = useCallback(() => setActiveTab(1), []);
   const setTabToLdap = useCallback(() => setActiveTab(1), []);
   const setTabToGithub = useCallback(() => setActiveTab(2), []);
   const setTabToGithub = useCallback(() => setActiveTab(2), []);
   const setTabToGoogle = useCallback(() => setActiveTab(3), []);
   const setTabToGoogle = useCallback(() => setActiveTab(3), []);
-  const handleUsernameChange = useCallback((username: string) => setUsername(username), []);
-  const handlePasswordChange = useCallback((password: string) => setPassword(password), []);
+  const handleUsernameChange = useCallback(
+    (username: string) => setUsername(username),
+    [],
+  );
+  const handlePasswordChange = useCallback(
+    (password: string) => setPassword(password),
+    [],
+  );
 
 
   return (
   return (
     <>
     <>
       <ModalHeader toggle={onClose}>
       <ModalHeader toggle={onClose}>
-        { t('admin:user_management.create_external_account') }
+        {t('admin:user_management.create_external_account')}
       </ModalHeader>
       </ModalHeader>
       <ModalBody>
       <ModalBody>
         <div>
         <div>
@@ -76,7 +93,10 @@ const AssociateModalSubstance = (props: AssociateModalSubstanceProps): JSX.Eleme
               className={`${activeTab === 1 ? 'active' : ''} d-flex gap-1 align-items-center`}
               className={`${activeTab === 1 ? 'active' : ''} d-flex gap-1 align-items-center`}
               onClick={setTabToLdap}
               onClick={setTabToLdap}
             >
             >
-              <span className="material-symbols-outlined fs-5">network_node</span> LDAP
+              <span className="material-symbols-outlined fs-5">
+                network_node
+              </span>{' '}
+              LDAP
             </NavLink>
             </NavLink>
             <NavLink
             <NavLink
               className={`${activeTab === 2 ? 'active' : ''} d-flex gap-1 align-items-center`}
               className={`${activeTab === 2 ? 'active' : ''} d-flex gap-1 align-items-center`}
@@ -88,7 +108,8 @@ const AssociateModalSubstance = (props: AssociateModalSubstanceProps): JSX.Eleme
               className={`${activeTab === 3 ? 'active' : ''} d-flex gap-1 align-items-center`}
               className={`${activeTab === 3 ? 'active' : ''} d-flex gap-1 align-items-center`}
               onClick={setTabToGoogle}
               onClick={setTabToGoogle}
             >
             >
-              <span className="growi-custom-icons">google</span> (TBD) Google OAuth
+              <span className="growi-custom-icons">google</span> (TBD) Google
+              OAuth
             </NavLink>
             </NavLink>
           </Nav>
           </Nav>
           <TabContent activeTab={activeTab}>
           <TabContent activeTab={activeTab}>
@@ -100,24 +121,23 @@ const AssociateModalSubstance = (props: AssociateModalSubstanceProps): JSX.Eleme
                 onChangePassword={handlePasswordChange}
                 onChangePassword={handlePasswordChange}
               />
               />
             </TabPane>
             </TabPane>
-            <TabPane tabId={2}>
-              TBD
-            </TabPane>
-            <TabPane tabId={3}>
-              TBD
-            </TabPane>
-            <TabPane tabId={4}>
-              TBD
-            </TabPane>
-            <TabPane tabId={5}>
-              TBD
-            </TabPane>
+            <TabPane tabId={2}>TBD</TabPane>
+            <TabPane tabId={3}>TBD</TabPane>
+            <TabPane tabId={4}>TBD</TabPane>
+            <TabPane tabId={5}>TBD</TabPane>
           </TabContent>
           </TabContent>
         </div>
         </div>
       </ModalBody>
       </ModalBody>
       <ModalFooter className="border-top-0">
       <ModalFooter className="border-top-0">
-        <button type="button" className="btn btn-primary mt-3" data-testid="add-external-account-button" onClick={clickAddLdapAccountHandler}>
-          <span className="material-symbols-outlined" aria-hidden="true">add_circle</span>
+        <button
+          type="button"
+          className="btn btn-primary mt-3"
+          data-testid="add-external-account-button"
+          onClick={clickAddLdapAccountHandler}
+        >
+          <span className="material-symbols-outlined" aria-hidden="true">
+            add_circle
+          </span>
           {t('add')}
           {t('add')}
         </button>
         </button>
       </ModalFooter>
       </ModalFooter>
@@ -132,11 +152,15 @@ const AssociateModal = (props: Props): JSX.Element => {
   const { isOpen, onClose } = props;
   const { isOpen, onClose } = props;
 
 
   return (
   return (
-    <Modal isOpen={isOpen} toggle={onClose} size="lg" data-testid="grw-associate-modal">
+    <Modal
+      isOpen={isOpen}
+      toggle={onClose}
+      size="lg"
+      data-testid="grw-associate-modal"
+    >
       {isOpen && <AssociateModalSubstance onClose={onClose} />}
       {isOpen && <AssociateModalSubstance onClose={onClose} />}
     </Modal>
     </Modal>
   );
   );
 };
 };
 
 
-
 export default AssociateModal;
 export default AssociateModal;

+ 110 - 57
apps/app/src/client/components/Me/BasicInfoSettings.tsx

@@ -1,22 +1,22 @@
-import React, { useState, useEffect, type JSX } from 'react';
-
+import React, { type JSX, useEffect, useState } from 'react';
 import type { IUser } from '@growi/core/dist/interfaces';
 import type { IUser } from '@growi/core/dist/interfaces';
 import { useAtomValue } from 'jotai';
 import { useAtomValue } from 'jotai';
-import { useTranslation, i18n } from 'next-i18next';
+import { i18n, useTranslation } from 'next-i18next';
 
 
 import { i18n as i18nConfig } from '^/config/next-i18next.config';
 import { i18n as i18nConfig } from '^/config/next-i18next.config';
 
 
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { registrationWhitelistAtom } from '~/states/server-configurations';
 import { registrationWhitelistAtom } from '~/states/server-configurations';
-import { useSWRxPersonalSettings, useUpdateBasicInfo } from '~/stores/personal-settings';
+import {
+  useSWRxPersonalSettings,
+  useUpdateBasicInfo,
+} from '~/stores/personal-settings';
 
 
 export const BasicInfoSettings = (): JSX.Element => {
 export const BasicInfoSettings = (): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const registrationWhitelist = useAtomValue(registrationWhitelistAtom);
   const registrationWhitelist = useAtomValue(registrationWhitelistAtom);
 
 
-  const {
-    data: personalSettingsInfo, error,
-  } = useSWRxPersonalSettings();
+  const { data: personalSettingsInfo, error } = useSWRxPersonalSettings();
 
 
   // Form state management
   // Form state management
   const [formData, setFormData] = useState<IUser | null>(null);
   const [formData, setFormData] = useState<IUser | null>(null);
@@ -30,23 +30,26 @@ export const BasicInfoSettings = (): JSX.Element => {
 
 
   const { trigger: updateBasicInfo, isMutating } = useUpdateBasicInfo();
   const { trigger: updateBasicInfo, isMutating } = useUpdateBasicInfo();
 
 
-  const submitHandler = async() => {
+  const submitHandler = async () => {
     try {
     try {
       if (formData == null) {
       if (formData == null) {
         throw new Error('personalSettingsInfo is not loaded');
         throw new Error('personalSettingsInfo is not loaded');
       }
       }
       await updateBasicInfo(formData);
       await updateBasicInfo(formData);
-      toastSuccess(t('toaster.update_successed', { target: t('Basic Info'), ns: 'commons' }));
-    }
-    catch (errs) {
+      toastSuccess(
+        t('toaster.update_successed', {
+          target: t('Basic Info'),
+          ns: 'commons',
+        }),
+      );
+    } catch (errs) {
       const err = errs[0];
       const err = errs[0];
       const message = err.message;
       const message = err.message;
       const code = err.code;
       const code = err.code;
 
 
       if (code === 'email-is-already-in-use') {
       if (code === 'email-is-already-in-use') {
         toastError(t('alert.email_is_already_in_use', { ns: 'commons' }));
         toastError(t('alert.email_is_already_in_use', { ns: 'commons' }));
-      }
-      else {
+      } else {
         toastError(message);
         toastError(message);
       }
       }
     }
     }
@@ -59,46 +62,65 @@ export const BasicInfoSettings = (): JSX.Element => {
     setFormData({ ...formData, ...updateData });
     setFormData({ ...formData, ...updateData });
   };
   };
 
 
-
   return (
   return (
     <>
     <>
-
       <div className="row mt-3 mt-md-4">
       <div className="row mt-3 mt-md-4">
-        <label htmlFor="userForm[name]" className="text-start text-md-end col-md-3 col-form-label">{t('Name')}</label>
+        <label
+          htmlFor="userForm[name]"
+          className="text-start text-md-end col-md-3 col-form-label"
+        >
+          {t('Name')}
+        </label>
         <div className="col-md-6">
         <div className="col-md-6">
           <input
           <input
             className="form-control"
             className="form-control"
             type="text"
             type="text"
             name="userForm[name]"
             name="userForm[name]"
             value={formData?.name || ''}
             value={formData?.name || ''}
-            onChange={e => changePersonalSettingsHandler({ name: e.target.value })}
+            onChange={(e) =>
+              changePersonalSettingsHandler({ name: e.target.value })
+            }
           />
           />
         </div>
         </div>
       </div>
       </div>
 
 
       <div className="row mt-3">
       <div className="row mt-3">
-        <label htmlFor="userForm[email]" className="text-start text-md-end col-md-3 col-form-label">{t('Email')}</label>
+        <label
+          htmlFor="userForm[email]"
+          className="text-start text-md-end col-md-3 col-form-label"
+        >
+          {t('Email')}
+        </label>
         <div className="col-md-6">
         <div className="col-md-6">
           <input
           <input
             className="form-control"
             className="form-control"
             type="text"
             type="text"
             name="userForm[email]"
             name="userForm[email]"
             value={formData?.email || ''}
             value={formData?.email || ''}
-            onChange={e => changePersonalSettingsHandler({ email: e.target.value })}
+            onChange={(e) =>
+              changePersonalSettingsHandler({ email: e.target.value })
+            }
           />
           />
-          {registrationWhitelist != null && registrationWhitelist.length !== 0 && (
-            <div className="form-text text-muted">
-              {t('page_register.form_help.email')}
-              <ul>
-                {registrationWhitelist.map(data => <li key={data}><code>{data}</code></li>)}
-              </ul>
-            </div>
-          )}
+          {registrationWhitelist != null &&
+            registrationWhitelist.length !== 0 && (
+              <div className="form-text text-muted">
+                {t('page_register.form_help.email')}
+                <ul>
+                  {registrationWhitelist.map((data) => (
+                    <li key={data}>
+                      <code>{data}</code>
+                    </li>
+                  ))}
+                </ul>
+              </div>
+            )}
         </div>
         </div>
       </div>
       </div>
 
 
       <div className="row mt-3">
       <div className="row mt-3">
-        <label className="text-start text-md-end col-md-3 col-form-label">{t('Disclose E-mail')}</label>
+        <span className="text-start text-md-end col-md-3 col-form-label">
+          {t('Disclose E-mail')}
+        </span>
         <div className="col-md-6 my-auto">
         <div className="col-md-6 my-auto">
           <div className="form-check form-check-inline me-4">
           <div className="form-check form-check-inline me-4">
             <input
             <input
@@ -107,9 +129,16 @@ export const BasicInfoSettings = (): JSX.Element => {
               className="form-check-input"
               className="form-check-input"
               name="userForm[isEmailPublished]"
               name="userForm[isEmailPublished]"
               checked={formData?.isEmailPublished === true}
               checked={formData?.isEmailPublished === true}
-              onChange={() => changePersonalSettingsHandler({ isEmailPublished: true })}
+              onChange={() =>
+                changePersonalSettingsHandler({ isEmailPublished: true })
+              }
             />
             />
-            <label className="form-label form-check-label mb-0" htmlFor="radioEmailShow">{t('Show')}</label>
+            <label
+              className="form-label form-check-label mb-0"
+              htmlFor="radioEmailShow"
+            >
+              {t('Show')}
+            </label>
           </div>
           </div>
           <div className="form-check form-check-inline">
           <div className="form-check form-check-inline">
             <input
             <input
@@ -118,40 +147,63 @@ export const BasicInfoSettings = (): JSX.Element => {
               className="form-check-input"
               className="form-check-input"
               name="userForm[isEmailPublished]"
               name="userForm[isEmailPublished]"
               checked={formData?.isEmailPublished === false}
               checked={formData?.isEmailPublished === false}
-              onChange={() => changePersonalSettingsHandler({ isEmailPublished: false })}
+              onChange={() =>
+                changePersonalSettingsHandler({ isEmailPublished: false })
+              }
             />
             />
-            <label className="form-label form-check-label mb-0" htmlFor="radioEmailHide">{t('Hide')}</label>
+            <label
+              className="form-label form-check-label mb-0"
+              htmlFor="radioEmailHide"
+            >
+              {t('Hide')}
+            </label>
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>
 
 
       <div className="row mt-3">
       <div className="row mt-3">
-        <label className="text-start text-md-end col-md-3 col-form-label">{t('Language')}</label>
+        <span className="text-start text-md-end col-md-3 col-form-label">
+          {t('Language')}
+        </span>
         <div className="col-md-6 my-auto">
         <div className="col-md-6 my-auto">
-          {
-            i18nConfig.locales.map((locale) => {
-              if (i18n == null) { return }
-              const fixedT = i18n.getFixedT(locale);
-
-              return (
-                <div key={locale} className="form-check form-check-inline me-4">
-                  <input
-                    type="radio"
-                    id={`radioLang${locale}`}
-                    className="form-check-input"
-                    name="userForm[lang]"
-                    checked={formData?.lang === locale}
-                    onChange={() => changePersonalSettingsHandler({ lang: locale as IUser['lang'] })}
-                  />
-                  <label className="form-label form-check-label mb-0" htmlFor={`radioLang${locale}`}>{fixedT('meta.display_name') as string}</label>
-                </div>
-              );
-            })
-          }
+          {i18nConfig.locales.map((locale) => {
+            if (i18n == null) {
+              return null;
+            }
+            const fixedT = i18n.getFixedT(locale);
+
+            return (
+              <div key={locale} className="form-check form-check-inline me-4">
+                <input
+                  type="radio"
+                  id={`radioLang${locale}`}
+                  className="form-check-input"
+                  name="userForm[lang]"
+                  checked={formData?.lang === locale}
+                  onChange={() =>
+                    changePersonalSettingsHandler({
+                      lang: locale as IUser['lang'],
+                    })
+                  }
+                />
+                <label
+                  className="form-label form-check-label mb-0"
+                  htmlFor={`radioLang${locale}`}
+                >
+                  {fixedT('meta.display_name') as string}
+                </label>
+              </div>
+            );
+          })}
         </div>
         </div>
       </div>
       </div>
       <div className="row mt-3">
       <div className="row mt-3">
-        <label htmlFor="userForm[slackMemberId]" className="text-start text-md-end col-md-3 col-form-label">{t('Slack Member ID')}</label>
+        <label
+          htmlFor="userForm[slackMemberId]"
+          className="text-start text-md-end col-md-3 col-form-label"
+        >
+          {t('Slack Member ID')}
+        </label>
         <div className="col-md-6">
         <div className="col-md-6">
           <input
           <input
             className="form-control"
             className="form-control"
@@ -159,7 +211,9 @@ export const BasicInfoSettings = (): JSX.Element => {
             key="slackMemberId"
             key="slackMemberId"
             name="userForm[slackMemberId]"
             name="userForm[slackMemberId]"
             value={formData?.slackMemberId || ''}
             value={formData?.slackMemberId || ''}
-            onChange={e => changePersonalSettingsHandler({ slackMemberId: e.target.value })}
+            onChange={(e) =>
+              changePersonalSettingsHandler({ slackMemberId: e.target.value })
+            }
           />
           />
         </div>
         </div>
       </div>
       </div>
@@ -177,7 +231,6 @@ export const BasicInfoSettings = (): JSX.Element => {
           </button>
           </button>
         </div>
         </div>
       </div>
       </div>
-
     </>
     </>
   );
   );
 };
 };

+ 50 - 24
apps/app/src/client/components/Me/ColorModeSettings.tsx

@@ -1,66 +1,92 @@
-import React, { useCallback, type JSX } from 'react';
-
+import React, { type JSX, useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
 import { Themes, useNextThemes } from '~/stores-universal/use-next-themes';
 import { Themes, useNextThemes } from '~/stores-universal/use-next-themes';
 
 
-
 type ColorModeSettingsButtonProps = {
 type ColorModeSettingsButtonProps = {
-  isActive: boolean,
-  children?: React.ReactNode,
-  onClick?: () => void,
-}
+  isActive: boolean;
+  children?: React.ReactNode;
+  onClick?: () => void;
+};
 
 
-const ColorModeSettingsButton = ({ isActive, children, onClick }: ColorModeSettingsButtonProps): JSX.Element => {
+const ColorModeSettingsButton = ({
+  isActive,
+  children,
+  onClick,
+}: ColorModeSettingsButtonProps): JSX.Element => {
   return (
   return (
     <button
     <button
       type="button"
       type="button"
       onClick={onClick}
       onClick={onClick}
       className={`btn py-2 px-4 fw-bold border-3 ${isActive ? 'btn-outline-primary' : 'btn-outline-neutral-secondary'}`}
       className={`btn py-2 px-4 fw-bold border-3 ${isActive ? 'btn-outline-primary' : 'btn-outline-neutral-secondary'}`}
     >
     >
-      { children }
+      {children}
     </button>
     </button>
   );
   );
 };
 };
 
 
-
 export const ColorModeSettings = (): JSX.Element => {
 export const ColorModeSettings = (): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const { setTheme, theme } = useNextThemes();
   const { setTheme, theme } = useNextThemes();
 
 
-  const isActive = useCallback((targetTheme: Themes) => {
-    return targetTheme === theme;
-  }, [theme]);
+  const isActive = useCallback(
+    (targetTheme: Themes) => {
+      return targetTheme === theme;
+    },
+    [theme],
+  );
 
 
   return (
   return (
     <div>
     <div>
-      <h2 className="border-bottom pb-2 mb-4 fs-4">{t('color_mode_settings.settings')}</h2>
+      <h2 className="border-bottom pb-2 mb-4 fs-4">
+        {t('color_mode_settings.settings')}
+      </h2>
 
 
       <div className="offset-md-3">
       <div className="offset-md-3">
-
         <div className="d-flex column-gap-3">
         <div className="d-flex column-gap-3">
-
-          <ColorModeSettingsButton isActive={isActive(Themes.LIGHT)} onClick={() => { setTheme(Themes.LIGHT) }}>
-            <span className="material-symbols-outlined fs-5 me-1">light_mode</span>
+          <ColorModeSettingsButton
+            isActive={isActive(Themes.LIGHT)}
+            onClick={() => {
+              setTheme(Themes.LIGHT);
+            }}
+          >
+            <span className="material-symbols-outlined fs-5 me-1">
+              light_mode
+            </span>
             <span>{t('color_mode_settings.light')}</span>
             <span>{t('color_mode_settings.light')}</span>
           </ColorModeSettingsButton>
           </ColorModeSettingsButton>
 
 
-          <ColorModeSettingsButton isActive={isActive(Themes.DARK)} onClick={() => { setTheme(Themes.DARK) }}>
-            <span className="material-symbols-outlined fs-5 me-1">dark_mode</span>
+          <ColorModeSettingsButton
+            isActive={isActive(Themes.DARK)}
+            onClick={() => {
+              setTheme(Themes.DARK);
+            }}
+          >
+            <span className="material-symbols-outlined fs-5 me-1">
+              dark_mode
+            </span>
             <span>{t('color_mode_settings.dark')}</span>
             <span>{t('color_mode_settings.dark')}</span>
           </ColorModeSettingsButton>
           </ColorModeSettingsButton>
 
 
-          <ColorModeSettingsButton isActive={isActive(Themes.SYSTEM)} onClick={() => { setTheme(Themes.SYSTEM) }}>
+          <ColorModeSettingsButton
+            isActive={isActive(Themes.SYSTEM)}
+            onClick={() => {
+              setTheme(Themes.SYSTEM);
+            }}
+          >
             <span className="material-symbols-outlined fs-5 me-1">devices</span>
             <span className="material-symbols-outlined fs-5 me-1">devices</span>
             <span>{t('color_mode_settings.system')}</span>
             <span>{t('color_mode_settings.system')}</span>
           </ColorModeSettingsButton>
           </ColorModeSettingsButton>
-
         </div>
         </div>
 
 
         <div className="mt-3 text-muted small">
         <div className="mt-3 text-muted small">
-          {/* eslint-disable-next-line react/no-danger */}
-          <span dangerouslySetInnerHTML={{ __html: t('color_mode_settings.description') }} />
+          <span
+            // biome-ignore lint/security/noDangerouslySetInnerHtml: ignore
+            dangerouslySetInnerHTML={{
+              __html: t('color_mode_settings.description'),
+            }}
+          />
         </div>
         </div>
       </div>
       </div>
     </div>
     </div>

+ 51 - 28
apps/app/src/client/components/Me/DisassociateModal.tsx

@@ -1,54 +1,63 @@
-import React, { useCallback } from 'react';
-
+import type React from 'react';
+import { useCallback } from 'react';
 import type { HasObjectId, IExternalAccount } from '@growi/core';
 import type { HasObjectId, IExternalAccount } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-import {
-  Modal,
-  ModalHeader,
-  ModalBody,
-  ModalFooter,
-} from 'reactstrap';
+import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import type { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
 import type { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
-import { useDisassociateLdapAccount, useSWRxPersonalExternalAccounts } from '~/stores/personal-settings';
+import {
+  useDisassociateLdapAccount,
+  useSWRxPersonalExternalAccounts,
+} from '~/stores/personal-settings';
 
 
 type Props = {
 type Props = {
-  isOpen: boolean,
-  onClose: () => void,
-  accountForDisassociate: IExternalAccount<IExternalAuthProviderType> & HasObjectId,
-}
+  isOpen: boolean;
+  onClose: () => void;
+  accountForDisassociate: IExternalAccount<IExternalAuthProviderType> &
+    HasObjectId;
+};
 
 
 /**
 /**
  * DisassociateModalSubstance - Presentation component (heavy logic, rendered only when isOpen)
  * DisassociateModalSubstance - Presentation component (heavy logic, rendered only when isOpen)
  */
  */
 type DisassociateModalSubstanceProps = {
 type DisassociateModalSubstanceProps = {
   onClose: () => void;
   onClose: () => void;
-  accountForDisassociate: IExternalAccount<IExternalAuthProviderType> & HasObjectId;
+  accountForDisassociate: IExternalAccount<IExternalAuthProviderType> &
+    HasObjectId;
 };
 };
 
 
-const DisassociateModalSubstance = (props: DisassociateModalSubstanceProps): React.JSX.Element => {
+const DisassociateModalSubstance = (
+  props: DisassociateModalSubstanceProps,
+): React.JSX.Element => {
   const { onClose, accountForDisassociate } = props;
   const { onClose, accountForDisassociate } = props;
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const { mutate: mutatePersonalExternalAccounts } = useSWRxPersonalExternalAccounts();
+  const { mutate: mutatePersonalExternalAccounts } =
+    useSWRxPersonalExternalAccounts();
   const { trigger: disassociateLdapAccount } = useDisassociateLdapAccount();
   const { trigger: disassociateLdapAccount } = useDisassociateLdapAccount();
 
 
   const { providerType, accountId } = accountForDisassociate;
   const { providerType, accountId } = accountForDisassociate;
 
 
-  const disassociateAccountHandler = useCallback(async() => {
+  const disassociateAccountHandler = useCallback(async () => {
     try {
     try {
       await disassociateLdapAccount({ providerType, accountId });
       await disassociateLdapAccount({ providerType, accountId });
       onClose();
       onClose();
       toastSuccess(t('security_settings.updated_general_security_setting'));
       toastSuccess(t('security_settings.updated_general_security_setting'));
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
       toastError(err);
     }
     }
 
 
     if (mutatePersonalExternalAccounts != null) {
     if (mutatePersonalExternalAccounts != null) {
       mutatePersonalExternalAccounts();
       mutatePersonalExternalAccounts();
     }
     }
-  }, [accountId, disassociateLdapAccount, mutatePersonalExternalAccounts, onClose, providerType, t]);
+  }, [
+    accountId,
+    disassociateLdapAccount,
+    mutatePersonalExternalAccounts,
+    onClose,
+    providerType,
+    t,
+  ]);
 
 
   return (
   return (
     <>
     <>
@@ -56,16 +65,31 @@ const DisassociateModalSubstance = (props: DisassociateModalSubstanceProps): Rea
         {t('personal_settings.disassociate_external_account')}
         {t('personal_settings.disassociate_external_account')}
       </ModalHeader>
       </ModalHeader>
       <ModalBody>
       <ModalBody>
-        {/* eslint-disable-next-line react/no-danger */}
-        <p dangerouslySetInnerHTML={{ __html: t('personal_settings.disassociate_external_account_desc', { providerType, accountId }) }} />
+        <p
+          // biome-ignore lint/security/noDangerouslySetInnerHtml: ignore
+          dangerouslySetInnerHTML={{
+            __html: t('personal_settings.disassociate_external_account_desc', {
+              providerType,
+              accountId,
+            }),
+          }}
+        />
       </ModalBody>
       </ModalBody>
       <ModalFooter>
       <ModalFooter>
-        <button type="button" className="btn btn-sm btn-outline-secondary" onClick={onClose}>
-          { t('Cancel') }
+        <button
+          type="button"
+          className="btn btn-sm btn-outline-secondary"
+          onClick={onClose}
+        >
+          {t('Cancel')}
         </button>
         </button>
-        <button type="button" className="btn btn-sm btn-danger" onClick={disassociateAccountHandler}>
+        <button
+          type="button"
+          className="btn btn-sm btn-danger"
+          onClick={disassociateAccountHandler}
+        >
           <span className="material-symbols-outlined">link_off</span>
           <span className="material-symbols-outlined">link_off</span>
-          { t('Disassociate') }
+          {t('Disassociate')}
         </button>
         </button>
       </ModalFooter>
       </ModalFooter>
     </>
     </>
@@ -90,5 +114,4 @@ const DisassociateModal = (props: Props): React.JSX.Element => {
   );
   );
 };
 };
 
 
-
 export default DisassociateModal;
 export default DisassociateModal;

+ 1 - 2
apps/app/src/client/components/Me/EditorSettings.tsx

@@ -1,4 +1,4 @@
-import { memo, type JSX } from 'react';
+import { type JSX, memo } from 'react';
 
 
 export const EditorSettings = memo((): JSX.Element => {
 export const EditorSettings = memo((): JSX.Element => {
   // const { t } = useTranslation();
   // const { t } = useTranslation();
@@ -21,7 +21,6 @@ export const EditorSettings = memo((): JSX.Element => {
 
 
   return (
   return (
     <div data-testid="grw-editor-settings">
     <div data-testid="grw-editor-settings">
-
       {/*
       {/*
       <div className="row my-3">
       <div className="row my-3">
         <div className="offset-4 col-5">
         <div className="offset-4 col-5">

+ 29 - 23
apps/app/src/client/components/Me/ExternalAccountLinkedMe.jsx

@@ -1,19 +1,15 @@
 import React, { Fragment } from 'react';
 import React, { Fragment } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-
 import { useSWRxPersonalExternalAccounts } from '~/stores/personal-settings';
 import { useSWRxPersonalExternalAccounts } from '~/stores/personal-settings';
 
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
-
 import AssociateModal from './AssociateModal';
 import AssociateModal from './AssociateModal';
 import DisassociateModal from './DisassociateModal';
 import DisassociateModal from './DisassociateModal';
 import ExternalAccountRow from './ExternalAccountRow';
 import ExternalAccountRow from './ExternalAccountRow';
 
 
 class ExternalAccountLinkedMe extends React.Component {
 class ExternalAccountLinkedMe extends React.Component {
-
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
 
 
@@ -58,7 +54,7 @@ class ExternalAccountLinkedMe extends React.Component {
     return (
     return (
       <Fragment>
       <Fragment>
         <h2 className="border-bottom mt-4 pb-2 fs-4">
         <h2 className="border-bottom mt-4 pb-2 fs-4">
-          { t('admin:user_management.external_accounts') }
+          {t('admin:user_management.external_accounts')}
         </h2>
         </h2>
         <button
         <button
           type="button"
           type="button"
@@ -66,29 +62,35 @@ class ExternalAccountLinkedMe extends React.Component {
           className="btn btn-outline-secondary btn-sm pull-right mb-2"
           className="btn btn-outline-secondary btn-sm pull-right mb-2"
           onClick={this.openAssociateModal}
           onClick={this.openAssociateModal}
         >
         >
-          <span className="material-symbols-outlined" aria-hidden="true">add_circle</span>
+          <span className="material-symbols-outlined" aria-hidden="true">
+            add_circle
+          </span>
           Add
           Add
         </button>
         </button>
 
 
         <table className="table table-bordered table-user-list">
         <table className="table table-bordered table-user-list">
           <thead>
           <thead>
             <tr>
             <tr>
-              <th width="120px">{ t('admin:user_management.authentication_provider') }</th>
+              <th width="120px">
+                {t('admin:user_management.authentication_provider')}
+              </th>
               <th>
               <th>
                 <code>accountId</code>
                 <code>accountId</code>
               </th>
               </th>
-              <th width="200px">{ t('Created') }</th>
-              <th width="150px">{ t('Admin') }</th>
+              <th width="200px">{t('Created')}</th>
+              <th width="150px">{t('Admin')}</th>
             </tr>
             </tr>
           </thead>
           </thead>
           <tbody>
           <tbody>
-            {personalExternalAccounts != null && personalExternalAccounts.length > 0 && personalExternalAccounts.map(account => (
-              <ExternalAccountRow
-                account={account}
-                key={account._id}
-                openDisassociateModal={this.openDisassociateModal}
-              />
-            ))}
+            {personalExternalAccounts != null &&
+              personalExternalAccounts.length > 0 &&
+              personalExternalAccounts.map((account) => (
+                <ExternalAccountRow
+                  account={account}
+                  key={account._id}
+                  openDisassociateModal={this.openDisassociateModal}
+                />
+              ))}
           </tbody>
           </tbody>
         </table>
         </table>
 
 
@@ -97,19 +99,16 @@ class ExternalAccountLinkedMe extends React.Component {
           onClose={this.closeAssociateModal}
           onClose={this.closeAssociateModal}
         />
         />
 
 
-        {this.state.accountForDisassociate != null
-        && (
+        {this.state.accountForDisassociate != null && (
           <DisassociateModal
           <DisassociateModal
             isOpen={this.state.isDisassociateModalOpen}
             isOpen={this.state.isDisassociateModalOpen}
             onClose={this.closeDisassociateModal}
             onClose={this.closeDisassociateModal}
             accountForDisassociate={this.state.accountForDisassociate}
             accountForDisassociate={this.state.accountForDisassociate}
           />
           />
         )}
         )}
-
       </Fragment>
       </Fragment>
     );
     );
   }
   }
-
 }
 }
 
 
 ExternalAccountLinkedMe.propTypes = {
 ExternalAccountLinkedMe.propTypes = {
@@ -119,9 +118,16 @@ ExternalAccountLinkedMe.propTypes = {
 
 
 const ExternalAccountLinkedMeWrapperFC = (props) => {
 const ExternalAccountLinkedMeWrapperFC = (props) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const { data: personalExternalAccountsData } = useSWRxPersonalExternalAccounts();
-
-  return <ExternalAccountLinkedMe t={t} personalExternalAccounts={personalExternalAccountsData} {...props} />;
+  const { data: personalExternalAccountsData } =
+    useSWRxPersonalExternalAccounts();
+
+  return (
+    <ExternalAccountLinkedMe
+      t={t}
+      personalExternalAccounts={personalExternalAccountsData}
+      {...props}
+    />
+  );
 };
 };
 
 
 export default ExternalAccountLinkedMeWrapperFC;
 export default ExternalAccountLinkedMeWrapperFC;

+ 3 - 5
apps/app/src/client/components/Me/ExternalAccountRow.jsx

@@ -1,6 +1,4 @@
-
 import React from 'react';
 import React from 'react';
-
 import { format as dateFnsFormat } from 'date-fns/format';
 import { format as dateFnsFormat } from 'date-fns/format';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
@@ -11,9 +9,9 @@ const ExternalAccountRow = (props) => {
 
 
   return (
   return (
     <tr>
     <tr>
-      <td>{ account.providerType }</td>
+      <td>{account.providerType}</td>
       <td>
       <td>
-        <strong>{ account.accountId }</strong>
+        <strong>{account.accountId}</strong>
       </td>
       </td>
       <td>{dateFnsFormat(account.createdAt, 'yyyy-MM-dd')}</td>
       <td>{dateFnsFormat(account.createdAt, 'yyyy-MM-dd')}</td>
       <td className="text-center">
       <td className="text-center">
@@ -23,7 +21,7 @@ const ExternalAccountRow = (props) => {
           onClick={() => props.openDisassociateModal(account)}
           onClick={() => props.openDisassociateModal(account)}
         >
         >
           <span className="material-symbols-outlined">link_off</span>
           <span className="material-symbols-outlined">link_off</span>
-          { t('Disassociate') }
+          {t('Disassociate')}
         </button>
         </button>
       </td>
       </td>
     </tr>
     </tr>

+ 54 - 32
apps/app/src/client/components/Me/InAppNotificationSettings.tsx

@@ -1,16 +1,18 @@
 import type { FC } from 'react';
 import type { FC } from 'react';
-import React, { useState, useEffect, useCallback } from 'react';
-
+import React, { useCallback, useEffect, useState } from 'react';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
-import { subscribeRuleNames, SubscribeRuleDescriptions } from '~/interfaces/in-app-notification';
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import {
+  SubscribeRuleDescriptions,
+  subscribeRuleNames,
+} from '~/interfaces/in-app-notification';
 
 
 type SubscribeRule = {
 type SubscribeRule = {
-  name: string,
-  isEnabled: boolean,
-}
+  name: string;
+  isEnabled: boolean;
+};
 
 
 const subscribeRulesMenuItems = [
 const subscribeRulesMenuItems = [
   {
   {
@@ -19,26 +21,27 @@ const subscribeRulesMenuItems = [
   },
   },
 ];
 ];
 
 
-const isCheckedRule = (ruleName: string, subscribeRules: SubscribeRule[]) => (
-  subscribeRules.find(stateRule => (
-    stateRule.name === ruleName
-  ))?.isEnabled || false
-);
+const isCheckedRule = (ruleName: string, subscribeRules: SubscribeRule[]) =>
+  subscribeRules.find((stateRule) => stateRule.name === ruleName)?.isEnabled ||
+  false;
 
 
-const updateIsEnabled = (subscribeRules: SubscribeRule[], ruleName: string, isChecked: boolean) => {
+const updateIsEnabled = (
+  subscribeRules: SubscribeRule[],
+  ruleName: string,
+  isChecked: boolean,
+) => {
   const target = [{ name: ruleName, isEnabled: isChecked }];
   const target = [{ name: ruleName, isEnabled: isChecked }];
-  return subscribeRules
-    .filter(rule => rule.name !== ruleName)
-    .concat(target);
+  return subscribeRules.filter((rule) => rule.name !== ruleName).concat(target);
 };
 };
 
 
-
 const InAppNotificationSettings: FC = () => {
 const InAppNotificationSettings: FC = () => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const [subscribeRules, setSubscribeRules] = useState<SubscribeRule[]>([]);
   const [subscribeRules, setSubscribeRules] = useState<SubscribeRule[]>([]);
 
 
-  const initializeInAppNotificationSettings = useCallback(async() => {
-    const { data } = await apiv3Get('/personal-setting/in-app-notification-settings');
+  const initializeInAppNotificationSettings = useCallback(async () => {
+    const { data } = await apiv3Get(
+      '/personal-setting/in-app-notification-settings',
+    );
     const retrievedRules: SubscribeRule[] | null = data?.subscribeRules;
     const retrievedRules: SubscribeRule[] | null = data?.subscribeRules;
 
 
     if (retrievedRules != null && retrievedRules.length > 0) {
     if (retrievedRules != null && retrievedRules.length > 0) {
@@ -46,20 +49,32 @@ const InAppNotificationSettings: FC = () => {
     }
     }
   }, []);
   }, []);
 
 
-  const ruleCheckboxHandler = useCallback((ruleName: string, isChecked: boolean) => {
-    setSubscribeRules(prevState => updateIsEnabled(prevState, ruleName, isChecked));
-  }, []);
+  const ruleCheckboxHandler = useCallback(
+    (ruleName: string, isChecked: boolean) => {
+      setSubscribeRules((prevState) =>
+        updateIsEnabled(prevState, ruleName, isChecked),
+      );
+    },
+    [],
+  );
 
 
-  const updateSettingsHandler = useCallback(async() => {
+  const updateSettingsHandler = useCallback(async () => {
     try {
     try {
-      const { data } = await apiv3Put('/personal-setting/in-app-notification-settings', { subscribeRules });
+      const { data } = await apiv3Put(
+        '/personal-setting/in-app-notification-settings',
+        { subscribeRules },
+      );
       setSubscribeRules(data.subscribeRules);
       setSubscribeRules(data.subscribeRules);
-      toastSuccess(t('toaster.update_successed', { target: 'InAppNotification Settings', ns: 'commons' }));
-    }
-    catch (err) {
+      toastSuccess(
+        t('toaster.update_successed', {
+          target: 'InAppNotification Settings',
+          ns: 'commons',
+        }),
+      );
+    } catch (err) {
       toastError(err);
       toastError(err);
     }
     }
-  }, [subscribeRules, setSubscribeRules, t]);
+  }, [subscribeRules, t]);
 
 
   useEffect(() => {
   useEffect(() => {
     initializeInAppNotificationSettings();
     initializeInAppNotificationSettings();
@@ -67,11 +82,13 @@ const InAppNotificationSettings: FC = () => {
 
 
   return (
   return (
     <>
     <>
-      <h2 className="border-bottom pb-2 my-4 fs-4">{t('in_app_notification_settings.subscribe_settings')}</h2>
+      <h2 className="border-bottom pb-2 my-4 fs-4">
+        {t('in_app_notification_settings.subscribe_settings')}
+      </h2>
 
 
       <div className="row">
       <div className="row">
         <div className="offset-md-3 col-md-6 text-start">
         <div className="offset-md-3 col-md-6 text-start">
-          {subscribeRulesMenuItems.map(rule => (
+          {subscribeRulesMenuItems.map((rule) => (
             <div
             <div
               key={rule.name}
               key={rule.name}
               className="form-check form-switch form-check-success"
               className="form-check form-switch form-check-success"
@@ -81,9 +98,14 @@ const InAppNotificationSettings: FC = () => {
                 className="form-check-input"
                 className="form-check-input"
                 id={rule.name}
                 id={rule.name}
                 checked={isCheckedRule(rule.name, subscribeRules)}
                 checked={isCheckedRule(rule.name, subscribeRules)}
-                onChange={e => ruleCheckboxHandler(rule.name, e.target.checked)}
+                onChange={(e) =>
+                  ruleCheckboxHandler(rule.name, e.target.checked)
+                }
               />
               />
-              <label className="form-label form-check-label" htmlFor={rule.name}>
+              <label
+                className="form-label form-check-label"
+                htmlFor={rule.name}
+              >
                 <strong>{rule.name}</strong>
                 <strong>{rule.name}</strong>
               </label>
               </label>
               <p className="form-text text-muted small">
               <p className="form-text text-muted small">

+ 0 - 2
apps/app/src/client/components/Me/OtherSettings.tsx

@@ -3,9 +3,7 @@ import type { JSX } from 'react';
 import { ColorModeSettings } from './ColorModeSettings';
 import { ColorModeSettings } from './ColorModeSettings';
 import { UISettings } from './UISettings';
 import { UISettings } from './UISettings';
 
 
-
 const OtherSettings = (): JSX.Element => {
 const OtherSettings = (): JSX.Element => {
-
   return (
   return (
     <>
     <>
       <div className="mt-4">
       <div className="mt-4">

+ 71 - 35
apps/app/src/client/components/Me/PasswordSettings.jsx

@@ -1,15 +1,12 @@
 import React, { useCallback } from 'react';
 import React, { useCallback } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { useSWRxPersonalSettings } from '~/stores/personal-settings';
 import { useSWRxPersonalSettings } from '~/stores/personal-settings';
 
 
-
 class PasswordSettings extends React.Component {
 class PasswordSettings extends React.Component {
-
   constructor() {
   constructor() {
     super();
     super();
 
 
@@ -24,7 +21,6 @@ class PasswordSettings extends React.Component {
 
 
     this.submitHandler = this.submitHandler.bind(this);
     this.submitHandler = this.submitHandler.bind(this);
     this.onChangeOldPassword = this.onChangeOldPassword.bind(this);
     this.onChangeOldPassword = this.onChangeOldPassword.bind(this);
-
   }
   }
 
 
   async componentDidMount() {
   async componentDidMount() {
@@ -32,12 +28,10 @@ class PasswordSettings extends React.Component {
       const res = await apiv3Get('/personal-setting/is-password-set');
       const res = await apiv3Get('/personal-setting/is-password-set');
       const { isPasswordSet, minPasswordLength } = res.data;
       const { isPasswordSet, minPasswordLength } = res.data;
       this.setState({ isPasswordSet, minPasswordLength });
       this.setState({ isPasswordSet, minPasswordLength });
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
       toastError(err);
       this.setState({ retrieveError: err });
       this.setState({ retrieveError: err });
     }
     }
-
   }
   }
 
 
   async submitHandler() {
   async submitHandler() {
@@ -46,18 +40,24 @@ class PasswordSettings extends React.Component {
 
 
     try {
     try {
       await apiv3Put('/personal-setting/password', {
       await apiv3Put('/personal-setting/password', {
-        oldPassword, newPassword, newPasswordConfirm,
+        oldPassword,
+        newPassword,
+        newPasswordConfirm,
+      });
+      this.setState({
+        oldPassword: '',
+        newPassword: '',
+        newPasswordConfirm: '',
       });
       });
-      this.setState({ oldPassword: '', newPassword: '', newPasswordConfirm: '' });
       if (onSubmit != null) {
       if (onSubmit != null) {
         onSubmit();
         onSubmit();
       }
       }
-      toastSuccess(t('toaster.update_successed', { target: t('Password'), ns: 'commons' }));
-    }
-    catch (err) {
+      toastSuccess(
+        t('toaster.update_successed', { target: t('Password'), ns: 'commons' }),
+      );
+    } catch (err) {
       toastError(err);
       toastError(err);
     }
     }
-
   }
   }
 
 
   onChangeOldPassword(oldPassword) {
   onChangeOldPassword(oldPassword) {
@@ -75,62 +75,100 @@ class PasswordSettings extends React.Component {
   render() {
   render() {
     const { t } = this.props;
     const { t } = this.props;
     const { newPassword, newPasswordConfirm, minPasswordLength } = this.state;
     const { newPassword, newPasswordConfirm, minPasswordLength } = this.state;
-    const isIncorrectConfirmPassword = (newPassword !== newPasswordConfirm);
+    const isIncorrectConfirmPassword = newPassword !== newPasswordConfirm;
     if (this.state.retrieveError != null) {
     if (this.state.retrieveError != null) {
       throw new Error(this.state.retrieveError.message);
       throw new Error(this.state.retrieveError.message);
     }
     }
 
 
     return (
     return (
       <React.Fragment>
       <React.Fragment>
-        { (!this.state.isPasswordSet) && (
-          <div className="alert alert-warning">{ t('personal_settings.password_is_not_set') }</div>
-        ) }
-
-        {(this.state.isPasswordSet)
-          ? <h2 className="border-bottom mt-4 mb-5 pb-2 fs-4">{t('personal_settings.update_password')}</h2>
-          : <h2 className="border-bottom mt-4 mb-5 pb-2 fs-4">{t('personal_settings.set_new_password')}</h2>}
-        {(this.state.isPasswordSet)
-        && (
+        {!this.state.isPasswordSet && (
+          <div className="alert alert-warning">
+            {t('personal_settings.password_is_not_set')}
+          </div>
+        )}
+
+        {this.state.isPasswordSet ? (
+          <h2 className="border-bottom mt-4 mb-5 pb-2 fs-4">
+            {t('personal_settings.update_password')}
+          </h2>
+        ) : (
+          <h2 className="border-bottom mt-4 mb-5 pb-2 fs-4">
+            {t('personal_settings.set_new_password')}
+          </h2>
+        )}
+        {this.state.isPasswordSet && (
           <div className="row mb-3">
           <div className="row mb-3">
-            <label htmlFor="oldPassword" className="col-md-3 text-md-end col-form-label">{ t('personal_settings.current_password') }</label>
+            <label
+              htmlFor="oldPassword"
+              className="col-md-3 text-md-end col-form-label"
+            >
+              {t('personal_settings.current_password')}
+            </label>
             <div className="col-md-5">
             <div className="col-md-5">
               <input
               <input
                 className="form-control"
                 className="form-control"
                 type="password"
                 type="password"
                 name="oldPassword"
                 name="oldPassword"
                 value={this.state.oldPassword}
                 value={this.state.oldPassword}
-                onChange={(e) => { this.onChangeOldPassword(e.target.value) }}
+                onChange={(e) => {
+                  this.onChangeOldPassword(e.target.value);
+                }}
               />
               />
             </div>
             </div>
           </div>
           </div>
         )}
         )}
         <div className="row mb-3">
         <div className="row mb-3">
-          <label htmlFor="newPassword" className="col-md-3 text-md-end col-form-label">{t('personal_settings.new_password') }</label>
+          <label
+            htmlFor="newPassword"
+            className="col-md-3 text-md-end col-form-label"
+          >
+            {t('personal_settings.new_password')}
+          </label>
           <div className="col-md-5">
           <div className="col-md-5">
             {/* to prevent autocomplete username into userForm[email] in BasicInfoSettings component */}
             {/* to prevent autocomplete username into userForm[email] in BasicInfoSettings component */}
             {/* https://developer.mozilla.org/en-US/docs/Web/Security/Securing_your_site/Turning_off_form_autocompletion */}
             {/* https://developer.mozilla.org/en-US/docs/Web/Security/Securing_your_site/Turning_off_form_autocompletion */}
-            <input type="password" autoComplete="new-password" style={{ display: 'none' }} />
+            <input
+              type="password"
+              autoComplete="new-password"
+              style={{ display: 'none' }}
+            />
             <input
             <input
               className="form-control"
               className="form-control"
               type="password"
               type="password"
               name="newPassword"
               name="newPassword"
               value={this.state.newPassword}
               value={this.state.newPassword}
-              onChange={(e) => { this.onChangeNewPassword(e.target.value) }}
+              onChange={(e) => {
+                this.onChangeNewPassword(e.target.value);
+              }}
             />
             />
           </div>
           </div>
         </div>
         </div>
-        <div className={`row mb-3 ${isIncorrectConfirmPassword && 'has-error'}`}>
-          <label htmlFor="newPasswordConfirm" className="col-md-3 text-md-end col-form-label">{t('personal_settings.new_password_confirm') }</label>
+        <div
+          className={`row mb-3 ${isIncorrectConfirmPassword && 'has-error'}`}
+        >
+          <label
+            htmlFor="newPasswordConfirm"
+            className="col-md-3 text-md-end col-form-label"
+          >
+            {t('personal_settings.new_password_confirm')}
+          </label>
           <div className="col-md-5">
           <div className="col-md-5">
             <input
             <input
               className="form-control"
               className="form-control"
               type="password"
               type="password"
               name="newPasswordConfirm"
               name="newPasswordConfirm"
               value={this.state.newPasswordConfirm}
               value={this.state.newPasswordConfirm}
-              onChange={(e) => { this.onChangeNewPasswordConfirm(e.target.value) }}
+              onChange={(e) => {
+                this.onChangeNewPasswordConfirm(e.target.value);
+              }}
             />
             />
 
 
-            <p className="form-text text-muted">{t('page_register.form_help.password', { target: minPasswordLength }) }</p>
+            <p className="form-text text-muted">
+              {t('page_register.form_help.password', {
+                target: minPasswordLength,
+              })}
+            </p>
           </div>
           </div>
         </div>
         </div>
 
 
@@ -150,7 +188,6 @@ class PasswordSettings extends React.Component {
       </React.Fragment>
       </React.Fragment>
     );
     );
   }
   }
-
 }
 }
 
 
 PasswordSettings.propTypes = {
 PasswordSettings.propTypes = {
@@ -166,7 +203,6 @@ const PasswordSettingsWrapperFC = (props) => {
     mutatePersonalSettings();
     mutatePersonalSettings();
   }, [mutatePersonalSettings]);
   }, [mutatePersonalSettings]);
 
 
-
   return <PasswordSettings t={t} onSubmit={submitHandler} {...props} />;
   return <PasswordSettings t={t} onSubmit={submitHandler} {...props} />;
 };
 };
 
 

+ 69 - 13
apps/app/src/client/components/Me/PersonalSettings.jsx

@@ -1,10 +1,7 @@
-
 import React, { useMemo } from 'react';
 import React, { useMemo } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import CustomNavAndContents from '../CustomNavigation/CustomNavAndContents';
 import CustomNavAndContents from '../CustomNavigation/CustomNavAndContents';
-
 import ApiSettings from './ApiSettings';
 import ApiSettings from './ApiSettings';
 // import { EditorSettings } from './EditorSettings';
 // import { EditorSettings } from './EditorSettings';
 import ExternalAccountLinkedMe from './ExternalAccountLinkedMe';
 import ExternalAccountLinkedMe from './ExternalAccountLinkedMe';
@@ -13,29 +10,82 @@ import OtherSettings from './OtherSettings';
 import PasswordSettings from './PasswordSettings';
 import PasswordSettings from './PasswordSettings';
 import UserSettings from './UserSettings';
 import UserSettings from './UserSettings';
 
 
-const PersonalSettings = () => {
+const UserInformationIcon = () => (
+  <span
+    data-testid="user-infomation-tab-button"
+    className="material-symbols-outlined"
+  >
+    person
+  </span>
+);
+
+const ExternalAccountsIcon = () => (
+  <span
+    data-testid="external-accounts-tab-button"
+    className="material-symbols-outlined"
+  >
+    ungroup
+  </span>
+);
+
+const PasswordSettingsIcon = () => (
+  <span
+    data-testid="password-settings-tab-button"
+    className="material-symbols-outlined"
+  >
+    password
+  </span>
+);
 
 
+const ApiSettingsIcon = () => (
+  <span
+    data-testid="api-settings-tab-button"
+    className="material-symbols-outlined"
+  >
+    api
+  </span>
+);
+
+const InAppNotificationSettingsIcon = () => (
+  <span
+    data-testid="in-app-notification-settings-tab-button"
+    className="material-symbols-outlined"
+  >
+    notifications
+  </span>
+);
+
+const OtherSettingsIcon = () => (
+  <span
+    data-testid="other-settings-tab-button"
+    className="material-symbols-outlined"
+  >
+    settings
+  </span>
+);
+
+const PersonalSettings = () => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const navTabMapping = useMemo(() => {
   const navTabMapping = useMemo(() => {
     return {
     return {
       user_infomation: {
       user_infomation: {
-        Icon: () => <span data-testid="user-infomation-tab-button" className="material-symbols-outlined">person</span>,
+        Icon: UserInformationIcon,
         Content: UserSettings,
         Content: UserSettings,
         i18n: t('User Information'),
         i18n: t('User Information'),
       },
       },
       external_accounts: {
       external_accounts: {
-        Icon: () => <span data-testid="external-accounts-tab-button" className="material-symbols-outlined">ungroup</span>,
+        Icon: ExternalAccountsIcon,
         Content: ExternalAccountLinkedMe,
         Content: ExternalAccountLinkedMe,
         i18n: t('admin:user_management.external_accounts'),
         i18n: t('admin:user_management.external_accounts'),
       },
       },
       password_settings: {
       password_settings: {
-        Icon: () => <span data-testid="password-settings-tab-button" className="material-symbols-outlined">password</span>,
+        Icon: PasswordSettingsIcon,
         Content: PasswordSettings,
         Content: PasswordSettings,
         i18n: t('Password Settings'),
         i18n: t('Password Settings'),
       },
       },
       api_settings: {
       api_settings: {
-        Icon: () => <span data-testid="api-settings-tab-button" className="material-symbols-outlined">api</span>,
+        Icon: ApiSettingsIcon,
         Content: ApiSettings,
         Content: ApiSettings,
         i18n: t('API Settings'),
         i18n: t('API Settings'),
       },
       },
@@ -45,12 +95,12 @@ const PersonalSettings = () => {
       //   i18n: t('editor_settings.editor_settings'),
       //   i18n: t('editor_settings.editor_settings'),
       // },
       // },
       in_app_notification_settings: {
       in_app_notification_settings: {
-        Icon: () => <span data-testid="in-app-notification-settings-tab-button" className="material-symbols-outlined">notifications</span>,
+        Icon: InAppNotificationSettingsIcon,
         Content: InAppNotificationSettings,
         Content: InAppNotificationSettings,
         i18n: t('in_app_notification_settings.in_app_notification_settings'),
         i18n: t('in_app_notification_settings.in_app_notification_settings'),
       },
       },
       other_settings: {
       other_settings: {
-        Icon: () => <span data-testid="other-settings-tab-button" className="material-symbols-outlined">settings</span>,
+        Icon: OtherSettingsIcon,
         Content: OtherSettings,
         Content: OtherSettings,
         i18n: t('Other Settings'),
         i18n: t('Other Settings'),
       },
       },
@@ -62,17 +112,23 @@ const PersonalSettings = () => {
     const tab = window.location.hash?.substring(1);
     const tab = window.location.hash?.substring(1);
     let defaultTabIndex;
     let defaultTabIndex;
     Object.keys(navTabMapping).forEach((key, i) => {
     Object.keys(navTabMapping).forEach((key, i) => {
-      if (key === tab) { defaultTabIndex = i }
+      if (key === tab) {
+        defaultTabIndex = i;
+      }
     });
     });
     return defaultTabIndex;
     return defaultTabIndex;
   };
   };
 
 
   return (
   return (
     <div data-testid="grw-personal-settings">
     <div data-testid="grw-personal-settings">
-      <CustomNavAndContents defaultTabIndex={getDefaultTabIndex()} navTabMapping={navTabMapping} navigationMode="both" tabContentClasses={['px-0']} />
+      <CustomNavAndContents
+        defaultTabIndex={getDefaultTabIndex()}
+        navTabMapping={navTabMapping}
+        navigationMode="both"
+        tabContentClasses={['px-0']}
+      />
     </div>
     </div>
   );
   );
-
 };
 };
 
 
 export default PersonalSettings;
 export default PersonalSettings;

+ 128 - 59
apps/app/src/client/components/Me/ProfileImageSettings.tsx

@@ -1,86 +1,112 @@
-import React, { useCallback, useState, type JSX } from 'react';
-
-
+import React, { type JSX, useCallback, useState } from 'react';
 import { isPopulated } from '@growi/core';
 import { isPopulated } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import ImageCropModal from '~/client/components/Common/ImageCropModal';
 import ImageCropModal from '~/client/components/Common/ImageCropModal';
 import { apiPost, apiPostForm } from '~/client/util/apiv1-client';
 import { apiPost, apiPostForm } from '~/client/util/apiv1-client';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Put } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { useCurrentUser } from '~/states/global';
 import { useCurrentUser } from '~/states/global';
-import { generateGravatarSrc, GRAVATAR_DEFAULT } from '~/utils/gravatar';
+import { GRAVATAR_DEFAULT, generateGravatarSrc } from '~/utils/gravatar';
 
 
 const DEFAULT_IMAGE = '/images/icons/user.svg';
 const DEFAULT_IMAGE = '/images/icons/user.svg';
 
 
-
 const ProfileImageSettings = (): JSX.Element => {
 const ProfileImageSettings = (): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const currentUser = useCurrentUser();
   const currentUser = useCurrentUser();
 
 
-  const [isGravatarEnabled, setGravatarEnabled] = useState(currentUser?.isGravatarEnabled);
+  const [isGravatarEnabled, setGravatarEnabled] = useState(
+    currentUser?.isGravatarEnabled,
+  );
   const [uploadedPictureSrc, setUploadedPictureSrc] = useState(() => {
   const [uploadedPictureSrc, setUploadedPictureSrc] = useState(() => {
-    if (currentUser?.imageAttachment != null && isPopulated(currentUser.imageAttachment)) {
+    if (
+      currentUser?.imageAttachment != null &&
+      isPopulated(currentUser.imageAttachment)
+    ) {
       return currentUser.imageAttachment.filePathProxied ?? currentUser.image;
       return currentUser.imageAttachment.filePathProxied ?? currentUser.image;
     }
     }
     return currentUser?.image;
     return currentUser?.image;
   });
   });
 
 
   const [showImageCropModal, setShowImageCropModal] = useState(false);
   const [showImageCropModal, setShowImageCropModal] = useState(false);
-  const [imageCropSrc, setImageCropSrc] = useState<string|ArrayBuffer|null>(null);
-
-  const selectFileHandler = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
-    if (e.target.files == null || e.target.files.length === 0) {
-      return;
-    }
-
-    const reader = new FileReader();
-    reader.addEventListener('load', () => setImageCropSrc(reader.result));
-    reader.readAsDataURL(e.target.files[0]);
-
-    setShowImageCropModal(true);
-  }, []);
+  const [imageCropSrc, setImageCropSrc] = useState<string | ArrayBuffer | null>(
+    null,
+  );
 
 
-  const processImageCompletedHandler = useCallback(async(croppedImage) => {
-    try {
-      const formData = new FormData();
-      formData.append('file', croppedImage);
-      const response = await apiPostForm('/attachments.uploadProfileImage', formData);
+  const selectFileHandler = useCallback(
+    (e: React.ChangeEvent<HTMLInputElement>) => {
+      if (e.target.files == null || e.target.files.length === 0) {
+        return;
+      }
 
 
-      toastSuccess(t('toaster.update_successed', { target: t('Current Image'), ns: 'commons' }));
+      const reader = new FileReader();
+      reader.addEventListener('load', () => setImageCropSrc(reader.result));
+      reader.readAsDataURL(e.target.files[0]);
 
 
-      // eslint-disable-next-line @typescript-eslint/no-explicit-any
-      setUploadedPictureSrc((response as any).attachment.filePathProxied);
+      setShowImageCropModal(true);
+    },
+    [],
+  );
 
 
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [t]);
+  const processImageCompletedHandler = useCallback(
+    async (croppedImage) => {
+      try {
+        const formData = new FormData();
+        formData.append('file', croppedImage);
+        const response = await apiPostForm(
+          '/attachments.uploadProfileImage',
+          formData,
+        );
+
+        toastSuccess(
+          t('toaster.update_successed', {
+            target: t('Current Image'),
+            ns: 'commons',
+          }),
+        );
+
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+        setUploadedPictureSrc((response as any).attachment.filePathProxied);
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [t],
+  );
 
 
-  const deleteImageHandler = useCallback(async() => {
+  const deleteImageHandler = useCallback(async () => {
     try {
     try {
       await apiPost('/attachments.removeProfileImage');
       await apiPost('/attachments.removeProfileImage');
 
 
       setUploadedPictureSrc(undefined);
       setUploadedPictureSrc(undefined);
-      toastSuccess(t('toaster.update_successed', { target: t('Current Image'), ns: 'commons' }));
-    }
-    catch (err) {
+      toastSuccess(
+        t('toaster.update_successed', {
+          target: t('Current Image'),
+          ns: 'commons',
+        }),
+      );
+    } catch (err) {
       toastError(err);
       toastError(err);
     }
     }
   }, [t]);
   }, [t]);
 
 
-  const submit = useCallback(async() => {
+  const submit = useCallback(async () => {
     try {
     try {
-      const response = await apiv3Put('/personal-setting/image-type', { isGravatarEnabled });
+      const response = await apiv3Put('/personal-setting/image-type', {
+        isGravatarEnabled,
+      });
 
 
       const { userData } = response.data;
       const { userData } = response.data;
       setGravatarEnabled(userData.isGravatarEnabled);
       setGravatarEnabled(userData.isGravatarEnabled);
 
 
-      toastSuccess(t('toaster.update_successed', { target: t('Set Profile Image'), ns: 'commons' }));
-    }
-    catch (err) {
+      toastSuccess(
+        t('toaster.update_successed', {
+          target: t('Set Profile Image'),
+          ns: 'commons',
+        }),
+      );
+    } catch (err) {
       toastError(err);
       toastError(err);
     }
     }
   }, [isGravatarEnabled, t]);
   }, [isGravatarEnabled, t]);
@@ -104,15 +130,42 @@ const ProfileImageSettings = (): JSX.Element => {
                 checked={isGravatarEnabled}
                 checked={isGravatarEnabled}
                 onChange={() => setGravatarEnabled(true)}
                 onChange={() => setGravatarEnabled(true)}
               />
               />
-              <label className="form-label form-check-label" htmlFor="radioGravatar">
-                <img src={GRAVATAR_DEFAULT} className="me-1" data-vrt-blackout-profile /> Gravatar
+              <label
+                className="form-label form-check-label"
+                htmlFor="radioGravatar"
+              >
+                <img
+                  src={GRAVATAR_DEFAULT}
+                  alt="Gravatar"
+                  className="me-1"
+                  data-vrt-blackout-profile
+                />{' '}
+                Gravatar
               </label>
               </label>
-              <a href="https://gravatar.com/" target="_blank" rel="noopener noreferrer">
-                <small><span className="material-symbols-outlined ms-2 text-secondary" aria-hidden="true">info</span></small>
+              <a
+                href="https://gravatar.com/"
+                target="_blank"
+                rel="noopener noreferrer"
+              >
+                <small>
+                  <span
+                    className="material-symbols-outlined ms-2 text-secondary"
+                    aria-hidden="true"
+                  >
+                    info
+                  </span>
+                </small>
               </a>
               </a>
             </div>
             </div>
           </h5>
           </h5>
-          <img src={generateGravatarSrc(currentUser.email)} className="rounded-pill" width="64" height="64" data-vrt-blackout-profile />
+          <img
+            src={generateGravatarSrc(currentUser.email)}
+            alt="Gravatar"
+            className="rounded-pill"
+            width="64"
+            height="64"
+            data-vrt-blackout-profile
+          />
         </div>
         </div>
 
 
         <div className="col-md-7 mt-5 mt-md-0">
         <div className="col-md-7 mt-5 mt-md-0">
@@ -127,26 +180,44 @@ const ProfileImageSettings = (): JSX.Element => {
                 checked={!isGravatarEnabled}
                 checked={!isGravatarEnabled}
                 onChange={() => setGravatarEnabled(false)}
                 onChange={() => setGravatarEnabled(false)}
               />
               />
-              <label className="form-label form-check-label" htmlFor="radioUploadPicture">
-                { t('Upload Image') }
+              <label
+                className="form-label form-check-label"
+                htmlFor="radioUploadPicture"
+              >
+                {t('Upload Image')}
               </label>
               </label>
             </div>
             </div>
           </h5>
           </h5>
           <div className="row mt-3">
           <div className="row mt-3">
-            <label className="col-md-6 col-lg-4 col-form-label text-start">
-              { t('Current Image') }
-            </label>
+            <span className="col-md-6 col-lg-4 col-form-label text-start">
+              {t('Current Image')}
+            </span>
             <div className="col-md-6 col-lg-8">
             <div className="col-md-6 col-lg-8">
               <p className="mb-0">
               <p className="mb-0">
-                <img src={uploadedPictureSrc ?? DEFAULT_IMAGE} width="64" height="64" className="rounded-circle" id="settingUserPicture" />
+                <img
+                  src={uploadedPictureSrc ?? DEFAULT_IMAGE}
+                  alt={t('Current Image')}
+                  width="64"
+                  height="64"
+                  className="rounded-circle"
+                  id="settingUserPicture"
+                />
               </p>
               </p>
-              {uploadedPictureSrc && <button type="button" className="btn btn-danger mt-2" onClick={deleteImageHandler}>{ t('Delete Image') }</button>}
+              {uploadedPictureSrc && (
+                <button
+                  type="button"
+                  className="btn btn-danger mt-2"
+                  onClick={deleteImageHandler}
+                >
+                  {t('Delete Image')}
+                </button>
+              )}
             </div>
             </div>
           </div>
           </div>
           <div className="row align-items-center mt-3 mt-md-5">
           <div className="row align-items-center mt-3 mt-md-5">
-            <label className="col-md-6 col-lg-4 col-form-label text-start mt-3 mt-md-0">
+            <span className="col-md-6 col-lg-4 col-form-label text-start mt-3 mt-md-0">
               {t('Upload new image')}
               {t('Upload new image')}
-            </label>
+            </span>
             <div className="col-md-6 col-lg-8">
             <div className="col-md-6 col-lg-8">
               <input
               <input
                 type="file"
                 type="file"
@@ -175,10 +246,8 @@ const ProfileImageSettings = (): JSX.Element => {
           </button>
           </button>
         </div>
         </div>
       </div>
       </div>
-
     </>
     </>
   );
   );
-
 };
 };
 
 
 export default ProfileImageSettings;
 export default ProfileImageSettings;

+ 66 - 30
apps/app/src/client/components/Me/UISettings.tsx

@@ -1,33 +1,44 @@
-import { useCallback, type JSX } from 'react';
-
+import { type JSX, useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
 
 
 import { updateUserUISettings } from '~/client/services/user-ui-settings';
 import { updateUserUISettings } from '~/client/services/user-ui-settings';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { toastError, toastSuccess } from '~/client/util/toastr';
-import { useSetPreferCollapsedMode, useSidebarMode, useCollapsedContentsOpened } from '~/states/ui/sidebar';
+import {
+  useCollapsedContentsOpened,
+  useSetPreferCollapsedMode,
+  useSidebarMode,
+} from '~/states/ui/sidebar';
 
 
 import styles from './UISettings.module.scss';
 import styles from './UISettings.module.scss';
 
 
 const IconWithTooltip = ({
 const IconWithTooltip = ({
-  id, label, children, additionalClasses,
+  id,
+  label,
+  children,
+  additionalClasses,
 }: {
 }: {
-id: string,
-label: string,
-children: JSX.Element,
-additionalClasses: string
+  id: string;
+  label: string;
+  children: JSX.Element;
+  additionalClasses: string;
 }) => (
 }) => (
   <>
   <>
-    <div id={id} className={`${additionalClasses != null ? additionalClasses : ''}`}>{children}</div>
-    <UncontrolledTooltip placement="bottom" fade={false} target={id}>{label}</UncontrolledTooltip>
+    <div
+      id={id}
+      className={`${additionalClasses != null ? additionalClasses : ''}`}
+    >
+      {children}
+    </div>
+    <UncontrolledTooltip placement="bottom" fade={false} target={id}>
+      {label}
+    </UncontrolledTooltip>
   </>
   </>
 );
 );
 
 
 export const UISettings = (): JSX.Element => {
 export const UISettings = (): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const {
-    isDockMode, isCollapsedMode,
-  } = useSidebarMode();
+  const { isDockMode, isCollapsedMode } = useSidebarMode();
   const setPreferCollapsedMode = useSetPreferCollapsedMode();
   const setPreferCollapsedMode = useSetPreferCollapsedMode();
   const [, setCollapsedContentsOpened] = useCollapsedContentsOpened();
   const [, setCollapsedContentsOpened] = useCollapsedContentsOpened();
 
 
@@ -36,15 +47,20 @@ export const UISettings = (): JSX.Element => {
     setCollapsedContentsOpened(false);
     setCollapsedContentsOpened(false);
   }, [setPreferCollapsedMode, isCollapsedMode, setCollapsedContentsOpened]);
   }, [setPreferCollapsedMode, isCollapsedMode, setCollapsedContentsOpened]);
 
 
-  const updateButtonHandler = useCallback(async() => {
+  const updateButtonHandler = useCallback(async () => {
     try {
     try {
-      await updateUserUISettings({ preferCollapsedModeByUser: isCollapsedMode() });
-      toastSuccess(t('toaster.update_successed', { target: t('ui_settings.side_bar_mode.settings'), ns: 'commons' }));
-    }
-    catch (err) {
+      await updateUserUISettings({
+        preferCollapsedModeByUser: isCollapsedMode(),
+      });
+      toastSuccess(
+        t('toaster.update_successed', {
+          target: t('ui_settings.side_bar_mode.settings'),
+          ns: 'commons',
+        }),
+      );
+    } catch (err) {
       toastError(err);
       toastError(err);
     }
     }
-
   }, [isCollapsedMode, t]);
   }, [isCollapsedMode, t]);
 
 
   const renderSidebarModeSwitch = () => {
   const renderSidebarModeSwitch = () => {
@@ -60,7 +76,6 @@ export const UISettings = (): JSX.Element => {
               <span className="growi-custom-icons fs-6">sidebar-collapsed</span>
               <span className="growi-custom-icons fs-6">sidebar-collapsed</span>
             </IconWithTooltip>
             </IconWithTooltip>
             <div className="form-check form-switch ms-1">
             <div className="form-check form-switch ms-1">
-
               <input
               <input
                 id="swSidebarMode"
                 id="swSidebarMode"
                 className="form-check-input"
                 className="form-check-input"
@@ -68,40 +83,61 @@ export const UISettings = (): JSX.Element => {
                 checked={isDockMode()}
                 checked={isDockMode()}
                 onChange={toggleCollapsed}
                 onChange={toggleCollapsed}
               />
               />
-              <label className="form-label form-check-label" htmlFor="swSidebarMode"></label>
+              <label
+                className="form-label form-check-label"
+                htmlFor="swSidebarMode"
+              >
+                <span className="visually-hidden">
+                  {t('ui_settings.side_bar_mode.side_bar_mode_setting')}
+                </span>
+              </label>
             </div>
             </div>
-            <IconWithTooltip id="iwt-sidebar-dock" label="Dock" additionalClasses={styles['grw-sidebar-mode-icon']}>
+            <IconWithTooltip
+              id="iwt-sidebar-dock"
+              label="Dock"
+              additionalClasses={styles['grw-sidebar-mode-icon']}
+            >
               <span className="growi-custom-icons fs-6">sidebar-dock</span>
               <span className="growi-custom-icons fs-6">sidebar-dock</span>
             </IconWithTooltip>
             </IconWithTooltip>
           </div>
           </div>
           <div className="ms-2">
           <div className="ms-2">
-            <label className="form-label form-check-label" htmlFor="swSidebarMode">
+            <label
+              className="form-label form-check-label"
+              htmlFor="swSidebarMode"
+            >
               {t('ui_settings.side_bar_mode.side_bar_mode_setting')}
               {t('ui_settings.side_bar_mode.side_bar_mode_setting')}
             </label>
             </label>
           </div>
           </div>
         </div>
         </div>
-        <p className="form-text text-muted small">{t('ui_settings.side_bar_mode.description')}</p>
+        <p className="form-text text-muted small">
+          {t('ui_settings.side_bar_mode.description')}
+        </p>
       </>
       </>
     );
     );
   };
   };
 
 
   return (
   return (
     <>
     <>
-      <h2 className="border-bottom pb- mb-4 fs-4">{t('ui_settings.ui_settings')}</h2>
+      <h2 className="border-bottom pb- mb-4 fs-4">
+        {t('ui_settings.ui_settings')}
+      </h2>
 
 
       <div className="row justify-content-center">
       <div className="row justify-content-center">
         <div className="col-md-6">
         <div className="col-md-6">
+          {renderSidebarModeSwitch()}
 
 
-          { renderSidebarModeSwitch() }
-
-          <div>
-          </div>
+          <div></div>
         </div>
         </div>
       </div>
       </div>
 
 
       <div className="row my-3">
       <div className="row my-3">
         <div className="offset-4 col-5">
         <div className="offset-4 col-5">
-          <button data-testid="grw-ui-settings-update-btn" type="button" className="btn btn-primary" onClick={updateButtonHandler}>
+          <button
+            data-testid="grw-ui-settings-update-btn"
+            type="button"
+            className="btn btn-primary"
+            onClick={updateButtonHandler}
+          >
             {t('Update')}
             {t('Update')}
           </button>
           </button>
         </div>
         </div>

+ 3 - 2
apps/app/src/client/components/Me/UserSettings.tsx

@@ -1,5 +1,4 @@
 import React, { type JSX } from 'react';
 import React, { type JSX } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import { BasicInfoSettings } from './BasicInfoSettings';
 import { BasicInfoSettings } from './BasicInfoSettings';
@@ -15,7 +14,9 @@ const UserSettings = React.memo((): JSX.Element => {
         <BasicInfoSettings />
         <BasicInfoSettings />
       </div>
       </div>
       <div className="mb-5">
       <div className="mb-5">
-        <h2 className="border-bottom fs-4 mt-3 mt-md-5 pb-1">{t('Set Profile Image')}</h2>
+        <h2 className="border-bottom fs-4 mt-3 mt-md-5 pb-1">
+          {t('Set Profile Image')}
+        </h2>
         <ProfileImageSettings />
         <ProfileImageSettings />
       </div>
       </div>
     </div>
     </div>

+ 15 - 15
apps/app/src/client/components/PageTags/PageTags.tsx

@@ -1,31 +1,29 @@
 import type { FC, JSX } from 'react';
 import type { FC, JSX } from 'react';
 import React, { useState } from 'react';
 import React, { useState } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import { NotAvailableForGuest } from '../NotAvailableForGuest';
 import { NotAvailableForGuest } from '../NotAvailableForGuest';
 import { NotAvailableForReadOnlyUser } from '../NotAvailableForReadOnlyUser';
 import { NotAvailableForReadOnlyUser } from '../NotAvailableForReadOnlyUser';
 import { Skeleton } from '../Skeleton';
 import { Skeleton } from '../Skeleton';
-
 import RenderTagLabels from './RenderTagLabels';
 import RenderTagLabels from './RenderTagLabels';
 
 
 import styles from './TagLabels.module.scss';
 import styles from './TagLabels.module.scss';
 
 
 type Props = {
 type Props = {
-  tags?: string[],
-  isTagLabelsDisabled: boolean,
-  tagsUpdateInvoked?: (tags: string[]) => Promise<void> | void,
-  onClickEditTagsButton: () => void,
-}
+  tags?: string[];
+  isTagLabelsDisabled: boolean;
+  tagsUpdateInvoked?: (tags: string[]) => Promise<void> | void;
+  onClickEditTagsButton: () => void;
+};
 
 
 export const PageTagsSkeleton = (): JSX.Element => {
 export const PageTagsSkeleton = (): JSX.Element => {
-  return <Skeleton additionalClass={`${styles['grw-tag-labels-skeleton']} mb-2`} />;
+  return (
+    <Skeleton additionalClass={`${styles['grw-tag-labels-skeleton']} mb-2`} />
+  );
 };
 };
 
 
-export const PageTags:FC<Props> = (props: Props) => {
-  const {
-    tags, isTagLabelsDisabled, onClickEditTagsButton,
-  } = props;
+export const PageTags: FC<Props> = (props: Props) => {
+  const { tags, isTagLabelsDisabled, onClickEditTagsButton } = props;
   const [isHovered, setIsHovered] = useState(false);
   const [isHovered, setIsHovered] = useState(false);
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
@@ -39,8 +37,10 @@ export const PageTags:FC<Props> = (props: Props) => {
   const onMouseLeaveHandler = () => setIsHovered(false);
   const onMouseLeaveHandler = () => setIsHovered(false);
 
 
   return (
   return (
-    <div className={`${styles['grw-tag-labels']} grw-tag-labels d-flex align-items-center mb-2 ${printNoneClass}`} data-testid="grw-tag-labels">
-
+    <div
+      className={`${styles['grw-tag-labels']} grw-tag-labels d-flex align-items-center mb-2 ${printNoneClass}`}
+      data-testid="grw-tag-labels"
+    >
       {/* for mobile */}
       {/* for mobile */}
       <div className="d-flex d-lg-none">
       <div className="d-flex d-lg-none">
         <NotAvailableForGuest>
         <NotAvailableForGuest>
@@ -70,7 +70,7 @@ export const PageTags:FC<Props> = (props: Props) => {
           >
           >
             <span className="material-symbols-outlined me-1">local_offer</span>
             <span className="material-symbols-outlined me-1">local_offer</span>
             <span className="me-2">{t('Tags')}</span>
             <span className="me-2">{t('Tags')}</span>
-            {(isHovered && !isTagLabelsDisabled) && (
+            {isHovered && !isTagLabelsDisabled && (
               <span className="material-symbols-outlined p-0">edit</span>
               <span className="material-symbols-outlined p-0">edit</span>
             )}
             )}
           </button>
           </button>

+ 5 - 7
apps/app/src/client/components/PageTags/RenderTagLabels.tsx

@@ -1,30 +1,28 @@
 import React from 'react';
 import React from 'react';
-
 import SimpleBar from 'simplebar-react';
 import SimpleBar from 'simplebar-react';
 
 
 import { useSetSearchKeyword } from '~/states/search';
 import { useSetSearchKeyword } from '~/states/search';
 
 
 type RenderTagLabelsProps = {
 type RenderTagLabelsProps = {
-  tags: string[],
-}
+  tags: string[];
+};
 
 
 const RenderTagLabels = React.memo((props: RenderTagLabelsProps) => {
 const RenderTagLabels = React.memo((props: RenderTagLabelsProps) => {
   const { tags } = props;
   const { tags } = props;
 
 
   const setSearchKeyword = useSetSearchKeyword();
   const setSearchKeyword = useSetSearchKeyword();
 
 
-
   return (
   return (
     <SimpleBar className="grw-tag-simple-bar pe-1">
     <SimpleBar className="grw-tag-simple-bar pe-1">
-      {tags.map(tag => (
-        <a
+      {tags.map((tag) => (
+        <button
           key={tag}
           key={tag}
           type="button"
           type="button"
           className="grw-tag badge me-1 mb-1 text-truncate mw-100"
           className="grw-tag badge me-1 mb-1 text-truncate mw-100"
           onClick={() => setSearchKeyword(`tag:${tag}`)}
           onClick={() => setSearchKeyword(`tag:${tag}`)}
         >
         >
           {tag}
           {tag}
-        </a>
+        </button>
       ))}
       ))}
     </SimpleBar>
     </SimpleBar>
   );
   );

+ 42 - 24
apps/app/src/client/components/PageTags/TagEditModal/TagEditModal.tsx

@@ -1,26 +1,28 @@
-import React, {
-  useState, useCallback, useEffect, useMemo,
-} from 'react';
-
+import type React from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
+import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 
 import { useUpdateStateAfterSave } from '~/client/services/page-operation';
 import { useUpdateStateAfterSave } from '~/client/services/page-operation';
 import { apiPost } from '~/client/util/apiv1-client';
 import { apiPost } from '~/client/util/apiv1-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { toastError, toastSuccess } from '~/client/util/toastr';
-import { useTagEditModalStatus, useTagEditModalActions, type TagEditModalStatus } from '~/states/ui/modal/tag-edit';
+import {
+  type TagEditModalStatus,
+  useTagEditModalActions,
+  useTagEditModalStatus,
+} from '~/states/ui/modal/tag-edit';
 import { useSWRxTagsInfo } from '~/stores/page';
 import { useSWRxTagsInfo } from '~/stores/page';
 
 
 import { TagsInput } from './TagsInput';
 import { TagsInput } from './TagsInput';
 
 
 type TagEditModalSubstanceProps = {
 type TagEditModalSubstanceProps = {
-  tagEditModalData: TagEditModalStatus,
-  closeTagEditModal: () => void,
-}
+  tagEditModalData: TagEditModalStatus;
+  closeTagEditModal: () => void;
+};
 
 
-const TagEditModalSubstance: React.FC<TagEditModalSubstanceProps> = (props: TagEditModalSubstanceProps) => {
+const TagEditModalSubstance: React.FC<TagEditModalSubstanceProps> = (
+  props: TagEditModalSubstanceProps,
+) => {
   const { tagEditModalData, closeTagEditModal } = props;
   const { tagEditModalData, closeTagEditModal } = props;
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
@@ -38,13 +40,16 @@ const TagEditModalSubstance: React.FC<TagEditModalSubstanceProps> = (props: TagE
   }, [initTags]);
   }, [initTags]);
 
 
   // Memoized API request data
   // Memoized API request data
-  const updateTagsData = useMemo(() => ({
-    pageId,
-    revisionId,
-    tags,
-  }), [pageId, revisionId, tags]);
+  const updateTagsData = useMemo(
+    () => ({
+      pageId,
+      revisionId,
+      tags,
+    }),
+    [pageId, revisionId, tags],
+  );
 
 
-  const handleSubmit = useCallback(async() => {
+  const handleSubmit = useCallback(async () => {
     try {
     try {
       await apiPost('/tags.update', updateTagsData);
       await apiPost('/tags.update', updateTagsData);
       mutateTags();
       mutateTags();
@@ -52,8 +57,7 @@ const TagEditModalSubstance: React.FC<TagEditModalSubstanceProps> = (props: TagE
 
 
       toastSuccess('updated tags successfully');
       toastSuccess('updated tags successfully');
       closeTagEditModal();
       closeTagEditModal();
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
       toastError(err);
     }
     }
   }, [updateTagsData, mutateTags, updateStateAfterSave, closeTagEditModal]);
   }, [updateTagsData, mutateTags, updateStateAfterSave, closeTagEditModal]);
@@ -64,7 +68,12 @@ const TagEditModalSubstance: React.FC<TagEditModalSubstanceProps> = (props: TagE
   }, []);
   }, []);
 
 
   return (
   return (
-    <Modal isOpen={isOpen} toggle={closeTagEditModal} id="edit-tag-modal" autoFocus={false}>
+    <Modal
+      isOpen={isOpen}
+      toggle={closeTagEditModal}
+      id="edit-tag-modal"
+      autoFocus={false}
+    >
       <ModalHeader tag="h4" toggle={closeTagEditModal}>
       <ModalHeader tag="h4" toggle={closeTagEditModal}>
         {t('tag_edit_modal.edit_tags')}
         {t('tag_edit_modal.edit_tags')}
       </ModalHeader>
       </ModalHeader>
@@ -72,13 +81,17 @@ const TagEditModalSubstance: React.FC<TagEditModalSubstanceProps> = (props: TagE
         <TagsInput tags={tags} onTagsUpdated={handleTagsUpdate} autoFocus />
         <TagsInput tags={tags} onTagsUpdated={handleTagsUpdate} autoFocus />
       </ModalBody>
       </ModalBody>
       <ModalFooter>
       <ModalFooter>
-        <button type="button" data-testid="tag-edit-done-btn" className="btn btn-primary" onClick={handleSubmit}>
+        <button
+          type="button"
+          data-testid="tag-edit-done-btn"
+          className="btn btn-primary"
+          onClick={handleSubmit}
+        >
           {t('tag_edit_modal.done')}
           {t('tag_edit_modal.done')}
         </button>
         </button>
       </ModalFooter>
       </ModalFooter>
     </Modal>
     </Modal>
   );
   );
-
 };
 };
 
 
 export const TagEditModal: React.FC = () => {
 export const TagEditModal: React.FC = () => {
@@ -89,5 +102,10 @@ export const TagEditModal: React.FC = () => {
     return <></>;
     return <></>;
   }
   }
 
 
-  return <TagEditModalSubstance tagEditModalData={tagEditModalData} closeTagEditModal={closeTagEditModal} />;
+  return (
+    <TagEditModalSubstance
+      tagEditModalData={tagEditModalData}
+      closeTagEditModal={closeTagEditModal}
+    />
+  );
 };
 };

+ 26 - 16
apps/app/src/client/components/PageTags/TagEditModal/TagsInput.tsx

@@ -1,6 +1,5 @@
 import type { FC, KeyboardEvent } from 'react';
 import type { FC, KeyboardEvent } from 'react';
-import React, { useRef, useState, useCallback } from 'react';
-
+import React, { useCallback, useRef, useState } from 'react';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import type { TypeaheadRef } from 'react-bootstrap-typeahead';
 import type { TypeaheadRef } from 'react-bootstrap-typeahead';
 import { AsyncTypeahead, Token } from 'react-bootstrap-typeahead';
 import { AsyncTypeahead, Token } from 'react-bootstrap-typeahead';
@@ -10,10 +9,10 @@ import { useSWRxTagsSearch } from '~/stores/tag';
 import styles from './TagsInput.module.scss';
 import styles from './TagsInput.module.scss';
 
 
 type Props = {
 type Props = {
-  tags: string[],
-  autoFocus: boolean,
-  onTagsUpdated: (tags: string[]) => void,
-}
+  tags: string[];
+  autoFocus: boolean;
+  onTagsUpdated: (tags: string[]) => void;
+};
 
 
 export const TagsInput: FC<Props> = (props: Props) => {
 export const TagsInput: FC<Props> = (props: Props) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -27,16 +26,22 @@ export const TagsInput: FC<Props> = (props: Props) => {
 
 
   const isLoading = error == null && tagsSearch === undefined;
   const isLoading = error == null && tagsSearch === undefined;
 
 
-  const changeHandler = useCallback((selected: string[]) => {
-    onTagsUpdated(selected);
-  }, [onTagsUpdated]);
+  const changeHandler = useCallback(
+    (selected: string[]) => {
+      onTagsUpdated(selected);
+    },
+    [onTagsUpdated],
+  );
 
 
-  const searchHandler = useCallback((query: string) => {
-    const tagsSearchData = tagsSearch?.tags || [];
-    setSearchQuery(query);
-    tagsSearchData.unshift(query);
-    setResultTags(Array.from(new Set(tagsSearchData)));
-  }, [tagsSearch?.tags]);
+  const searchHandler = useCallback(
+    (query: string) => {
+      const tagsSearchData = tagsSearch?.tags || [];
+      setSearchQuery(query);
+      tagsSearchData.unshift(query);
+      setResultTags(Array.from(new Set(tagsSearchData)));
+    },
+    [tagsSearch?.tags],
+  );
 
 
   const keyDownHandler = useCallback((event: KeyboardEvent<HTMLElement>) => {
   const keyDownHandler = useCallback((event: KeyboardEvent<HTMLElement>) => {
     if (event.code === 'Space') {
     if (event.code === 'Space') {
@@ -76,7 +81,12 @@ export const TagsInput: FC<Props> = (props: Props) => {
         // option is tag name
         // option is tag name
         renderToken={(option: string, { onRemove }, idx) => {
         renderToken={(option: string, { onRemove }, idx) => {
           return (
           return (
-            <Token key={idx} className="grw-tag badge mw-100 d-inline-flex p-0" option={option} onRemove={onRemove}>
+            <Token
+              key={idx}
+              className="grw-tag badge mw-100 d-inline-flex p-0"
+              option={option}
+              onRemove={onRemove}
+            >
               {option}
               {option}
             </Token>
             </Token>
           );
           );

+ 2 - 1
apps/app/src/client/components/PageTags/TagEditModal/dynamic.tsx

@@ -10,7 +10,8 @@ export const TagEditModalLazyLoaded = (): JSX.Element => {
 
 
   const TagEditModal = useLazyLoader<TagEditModalProps>(
   const TagEditModal = useLazyLoader<TagEditModalProps>(
     'tag-edit-modal',
     'tag-edit-modal',
-    () => import('./TagEditModal').then(mod => ({ default: mod.TagEditModal })),
+    () =>
+      import('./TagEditModal').then((mod) => ({ default: mod.TagEditModal })),
     status?.isOpen ?? false,
     status?.isOpen ?? false,
   );
   );
 
 

+ 74 - 55
apps/app/src/client/components/ReactMarkdownComponents/DrawioViewerWithEditButton.tsx

@@ -1,79 +1,98 @@
-import React, { useCallback, useState, type JSX } from 'react';
-
+import React, { type JSX, useCallback, useState } from 'react';
 import { globalEventTarget } from '@growi/core/dist/utils';
 import { globalEventTarget } from '@growi/core/dist/utils';
 import {
 import {
-  DrawioViewer,
   type DrawioEditByViewerProps,
   type DrawioEditByViewerProps,
+  DrawioViewer,
   type DrawioViewerProps,
   type DrawioViewerProps,
 } from '@growi/remark-drawio';
 } from '@growi/remark-drawio';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import { useCurrentPageYjsData } from '~/features/collaborative-editor/states';
 import { useCurrentPageYjsData } from '~/features/collaborative-editor/states';
-import { useIsGuestUser, useIsReadOnlyUser, useIsSharedUser } from '~/states/context';
+import {
+  useIsGuestUser,
+  useIsReadOnlyUser,
+  useIsSharedUser,
+} from '~/states/context';
 import { useShareLinkId } from '~/states/page/hooks';
 import { useShareLinkId } from '~/states/page/hooks';
 import { useIsRevisionOutdated } from '~/stores/page';
 import { useIsRevisionOutdated } from '~/stores/page';
 
 
 import '@growi/remark-drawio/dist/style.css';
 import '@growi/remark-drawio/dist/style.css';
-import styles from './DrawioViewerWithEditButton.module.scss';
 
 
+import styles from './DrawioViewerWithEditButton.module.scss';
 
 
-export const DrawioViewerWithEditButton = React.memo((props: DrawioViewerProps): JSX.Element => {
-  const { t } = useTranslation();
+export const DrawioViewerWithEditButton = React.memo(
+  (props: DrawioViewerProps): JSX.Element => {
+    const { t } = useTranslation();
 
 
-  const { bol, eol } = props;
+    const { bol, eol } = props;
 
 
-  const isGuestUser = useIsGuestUser();
-  const isReadOnlyUser = useIsReadOnlyUser();
-  const isSharedUser = useIsSharedUser();
-  const shareLinkId = useShareLinkId();
-  const isRevisionOutdated = useIsRevisionOutdated();
-  const currentPageYjsData = useCurrentPageYjsData();
+    const isGuestUser = useIsGuestUser();
+    const isReadOnlyUser = useIsReadOnlyUser();
+    const isSharedUser = useIsSharedUser();
+    const shareLinkId = useShareLinkId();
+    const isRevisionOutdated = useIsRevisionOutdated();
+    const currentPageYjsData = useCurrentPageYjsData();
 
 
-  const [isRendered, setRendered] = useState(false);
-  const [mxfile, setMxfile] = useState('');
+    const [isRendered, setRendered] = useState(false);
+    const [mxfile, setMxfile] = useState('');
 
 
-  const editButtonClickHandler = useCallback(() => {
-    globalEventTarget.dispatchEvent(new CustomEvent<DrawioEditByViewerProps>('launchDrawioModal', {
-      detail: {
-        bol, eol, drawioMxFile: mxfile,
-      },
-    }));
-  }, [bol, eol, mxfile]);
+    const editButtonClickHandler = useCallback(() => {
+      globalEventTarget.dispatchEvent(
+        new CustomEvent<DrawioEditByViewerProps>('launchDrawioModal', {
+          detail: {
+            bol,
+            eol,
+            drawioMxFile: mxfile,
+          },
+        }),
+      );
+    }, [bol, eol, mxfile]);
 
 
-  const renderingStartHandler = useCallback(() => {
-    setRendered(false);
-  }, []);
+    const renderingStartHandler = useCallback(() => {
+      setRendered(false);
+    }, []);
 
 
-  const renderingUpdatedHandler = useCallback((mxfile: string | null) => {
-    setRendered(mxfile != null);
+    const renderingUpdatedHandler = useCallback((mxfile: string | null) => {
+      setRendered(mxfile != null);
 
 
-    if (mxfile != null) {
-      setMxfile(mxfile);
-    }
-  }, []);
+      if (mxfile != null) {
+        setMxfile(mxfile);
+      }
+    }, []);
 
 
-  const isNoEditingUsers = currentPageYjsData?.awarenessStateSize == null || currentPageYjsData?.awarenessStateSize === 0;
-  const showEditButton = isNoEditingUsers
-     && !isRevisionOutdated
-     && isRendered
-     && !isGuestUser
-     && !isReadOnlyUser
-     && !isSharedUser
-     && shareLinkId == null;
+    const isNoEditingUsers =
+      currentPageYjsData?.awarenessStateSize == null ||
+      currentPageYjsData?.awarenessStateSize === 0;
+    const showEditButton =
+      isNoEditingUsers &&
+      !isRevisionOutdated &&
+      isRendered &&
+      !isGuestUser &&
+      !isReadOnlyUser &&
+      !isSharedUser &&
+      shareLinkId == null;
 
 
-  return (
-    <div className={`drawio-viewer-with-edit-button ${styles['drawio-viewer-with-edit-button']}`}>
-      { showEditButton && (
-        <button
-          type="button"
-          className="btn btn-sm btn-outline-secondary btn-edit-drawio"
-          onClick={editButtonClickHandler}
-        >
-          <span className="material-symbols-outlined me-1">edit_square</span>{t('Edit')}
-        </button>
-      ) }
-      <DrawioViewer {...props} onRenderingStart={renderingStartHandler} onRenderingUpdated={renderingUpdatedHandler} />
-    </div>
-  );
-});
+    return (
+      <div
+        className={`drawio-viewer-with-edit-button ${styles['drawio-viewer-with-edit-button']}`}
+      >
+        {showEditButton && (
+          <button
+            type="button"
+            className="btn btn-sm btn-outline-secondary btn-edit-drawio"
+            onClick={editButtonClickHandler}
+          >
+            <span className="material-symbols-outlined me-1">edit_square</span>
+            {t('Edit')}
+          </button>
+        )}
+        <DrawioViewer
+          {...props}
+          onRenderingStart={renderingStartHandler}
+          onRenderingUpdated={renderingUpdatedHandler}
+        />
+      </div>
+    );
+  },
+);
 DrawioViewerWithEditButton.displayName = 'DrawioViewerWithEditButton';
 DrawioViewerWithEditButton.displayName = 'DrawioViewerWithEditButton';

+ 73 - 45
apps/app/src/client/components/ReactMarkdownComponents/Header.tsx

@@ -1,66 +1,83 @@
-import {
-  useCallback, useEffect, useState, type JSX,
-} from 'react';
-
+import { type JSX, useCallback, useEffect, useState } from 'react';
+import { useRouter } from 'next/router';
 import { globalEventTarget } from '@growi/core/dist/utils';
 import { globalEventTarget } from '@growi/core/dist/utils';
 import type { Element } from 'hast';
 import type { Element } from 'hast';
-import { useRouter } from 'next/router';
 
 
+import { useStartEditing } from '~/client/services/use-start-editing';
 import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
 import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
-import { useCurrentPageYjsData, useCurrentPageYjsDataLoading } from '~/features/collaborative-editor/states';
-import { useIsGuestUser, useIsReadOnlyUser, useIsSharedUser } from '~/states/context';
+import {
+  useCurrentPageYjsData,
+  useCurrentPageYjsDataLoading,
+} from '~/features/collaborative-editor/states';
+import {
+  useIsGuestUser,
+  useIsReadOnlyUser,
+  useIsSharedUser,
+} from '~/states/context';
+import { useCurrentPagePath } from '~/states/page';
 import { useShareLinkId } from '~/states/page/hooks';
 import { useShareLinkId } from '~/states/page/hooks';
 import type { ReservedNextCaretLineEventDetail } from '~/states/ui/editor/reserved-next-caret-line';
 import type { ReservedNextCaretLineEventDetail } from '~/states/ui/editor/reserved-next-caret-line';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-
 import styles from './Header.module.scss';
 import styles from './Header.module.scss';
 
 
-
 const logger = loggerFactory('growi:components:Header');
 const logger = loggerFactory('growi:components:Header');
 const moduleClass = styles['revision-head'] ?? '';
 const moduleClass = styles['revision-head'] ?? '';
 
 
-
 function setCaretLine(lineNumber?: number): void {
 function setCaretLine(lineNumber?: number): void {
   if (lineNumber != null) {
   if (lineNumber != null) {
-    globalEventTarget.dispatchEvent(new CustomEvent<ReservedNextCaretLineEventDetail>('reservedNextCaretLine', {
-      detail: {
-        lineNumber,
-      },
-    }));
+    globalEventTarget.dispatchEvent(
+      new CustomEvent<ReservedNextCaretLineEventDetail>(
+        'reservedNextCaretLine',
+        {
+          detail: {
+            lineNumber,
+          },
+        },
+      ),
+    );
   }
   }
 }
 }
 
 
 type EditLinkProps = {
 type EditLinkProps = {
-  line?: number,
-}
+  line?: number;
+};
 
 
 /**
 /**
  * Inner FC to display edit link icon
  * Inner FC to display edit link icon
  */
  */
 const EditLink = (props: EditLinkProps): JSX.Element => {
 const EditLink = (props: EditLinkProps): JSX.Element => {
   const isDisabled = props.line == null;
   const isDisabled = props.line == null;
+  const startEditing = useStartEditing();
+  const currentPagePath = useCurrentPagePath();
+
+  const onClickHandler = useCallback(() => {
+    setCaretLine(props.line);
+    void startEditing(currentPagePath);
+  }, [currentPagePath, props.line, startEditing]);
 
 
   return (
   return (
     <span className="revision-head-edit-button">
     <span className="revision-head-edit-button">
-      <a href="#edit" aria-disabled={isDisabled} onClick={() => setCaretLine(props.line)}>
+      <button
+        type="button"
+        className="border-0 bg-transparent p-0"
+        disabled={isDisabled}
+        onClick={onClickHandler}
+      >
         <span className="material-symbols-outlined">edit_square</span>
         <span className="material-symbols-outlined">edit_square</span>
-      </a>
+      </button>
     </span>
     </span>
   );
   );
 };
 };
 
 
-
 type HeaderProps = {
 type HeaderProps = {
-  children: React.ReactNode,
-  node: Element,
-  id?: string,
-}
+  children: React.ReactNode;
+  node: Element;
+  id?: string;
+};
 
 
 export const Header = (props: HeaderProps): JSX.Element => {
 export const Header = (props: HeaderProps): JSX.Element => {
-  const {
-    node, id, children,
-  } = props;
+  const { node, id, children } = props;
 
 
   const isGuestUser = useIsGuestUser();
   const isGuestUser = useIsGuestUser();
   const isReadOnlyUser = useIsReadOnlyUser();
   const isReadOnlyUser = useIsReadOnlyUser();
@@ -75,16 +92,18 @@ export const Header = (props: HeaderProps): JSX.Element => {
 
 
   const CustomTag = node.tagName as keyof JSX.IntrinsicElements;
   const CustomTag = node.tagName as keyof JSX.IntrinsicElements;
 
 
-  const activateByHash = useCallback((url: string) => {
-    try {
-      const hash = (new URL(url, 'https://example.com')).hash.slice(1);
-      setActive(decodeURIComponent(hash) === id);
-    }
-    catch (err) {
-      logger.debug(err);
-      setActive(false);
-    }
-  }, [id]);
+  const activateByHash = useCallback(
+    (url: string) => {
+      try {
+        const hash = new URL(url, 'https://example.com').hash.slice(1);
+        setActive(decodeURIComponent(hash) === id);
+      } catch (err) {
+        logger.debug(err);
+        setActive(false);
+      }
+    },
+    [id],
+  );
 
 
   // init
   // init
   useEffect(() => {
   useEffect(() => {
@@ -111,27 +130,36 @@ export const Header = (props: HeaderProps): JSX.Element => {
     return () => {
     return () => {
       window.removeEventListener('hashchange', activeByHashWrapper);
       window.removeEventListener('hashchange', activeByHashWrapper);
     };
     };
-  }, [activateByHash, router.events]);
+  }, [activateByHash]);
 
 
   // TODO: currentPageYjsData?.hasYdocsNewerThanLatestRevision === false make to hide the edit button when a Yjs draft exists
   // TODO: currentPageYjsData?.hasYdocsNewerThanLatestRevision === false make to hide the edit button when a Yjs draft exists
   // This is because the current conditional logic cannot handle cases where the draft is an empty string.
   // This is because the current conditional logic cannot handle cases where the draft is an empty string.
   // It will be possible to address this TODO ySyncAnnotation become available for import.
   // It will be possible to address this TODO ySyncAnnotation become available for import.
   // Ref: https://github.com/yjs/y-codemirror.next/pull/30
   // Ref: https://github.com/yjs/y-codemirror.next/pull/30
-  const showEditButton = !isGuestUser && !isReadOnlyUser && !isSharedUser && shareLinkId == null
-                            && (!isLoadingCurrentPageYjsData && !currentPageYjsData?.hasYdocsNewerThanLatestRevision);
+  const showEditButton =
+    !isGuestUser &&
+    !isReadOnlyUser &&
+    !isSharedUser &&
+    shareLinkId == null &&
+    !isLoadingCurrentPageYjsData &&
+    !currentPageYjsData?.hasYdocsNewerThanLatestRevision;
 
 
   return (
   return (
     <>
     <>
-      <CustomTag id={id} className={`position-relative ${moduleClass} ${isActive ? styles.blink : ''} `}>
-        <NextLink href={`#${id}`} className="d-none d-md-inline revision-head-link position-absolute">
+      <CustomTag
+        id={id}
+        className={`position-relative ${moduleClass} ${isActive ? styles.blink : ''} `}
+      >
+        <NextLink
+          href={`#${id}`}
+          className="d-none d-md-inline revision-head-link position-absolute"
+        >
           #
           #
         </NextLink>
         </NextLink>
 
 
         {children}
         {children}
 
 
-        { showEditButton && (
-          <EditLink line={node.position?.start.line} />
-        ) }
+        {showEditButton && <EditLink line={node.position?.start.line} />}
       </CustomTag>
       </CustomTag>
     </>
     </>
   );
   );

+ 14 - 4
apps/app/src/client/components/ReactMarkdownComponents/LightBox.tsx

@@ -1,10 +1,13 @@
+import type React from 'react';
 import type { DetailedHTMLProps, ImgHTMLAttributes, JSX } from 'react';
 import type { DetailedHTMLProps, ImgHTMLAttributes, JSX } from 'react';
-import React, { useMemo, useState } from 'react';
-
+import { useMemo, useState } from 'react';
 import FsLightbox from 'fslightbox-react';
 import FsLightbox from 'fslightbox-react';
 import { createPortal } from 'react-dom';
 import { createPortal } from 'react-dom';
 
 
-type Props = DetailedHTMLProps<ImgHTMLAttributes<HTMLImageElement>, HTMLImageElement>
+type Props = DetailedHTMLProps<
+  ImgHTMLAttributes<HTMLImageElement>,
+  HTMLImageElement
+>;
 
 
 export const LightBox = (props: Props): JSX.Element => {
 export const LightBox = (props: Props): JSX.Element => {
   const [toggler, setToggler] = useState(false);
   const [toggler, setToggler] = useState(false);
@@ -26,7 +29,14 @@ export const LightBox = (props: Props): JSX.Element => {
   return (
   return (
     <>
     <>
       {/* eslint-disable-next-line @next/next/no-img-element */}
       {/* eslint-disable-next-line @next/next/no-img-element */}
-      <img alt={alt} {...rest} onClick={() => setToggler(!toggler)} />
+      <button
+        type="button"
+        className="border-0 bg-transparent p-0"
+        aria-label={alt ?? 'Open image'}
+        onClick={() => setToggler((prev) => !prev)}
+      >
+        <img alt={alt} {...rest} />
+      </button>
 
 
       {lightboxPortal}
       {lightboxPortal}
     </>
     </>

+ 49 - 23
apps/app/src/client/components/ReactMarkdownComponents/RichAttachment.tsx

@@ -1,21 +1,24 @@
 import React, { useCallback } from 'react';
 import React, { useCallback } from 'react';
-
+import Image from 'next/image';
 import { UserPicture } from '@growi/ui/dist/components';
 import { UserPicture } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-import Image from 'next/image';
 import prettyBytes from 'pretty-bytes';
 import prettyBytes from 'pretty-bytes';
 
 
-import { useIsGuestUser, useIsReadOnlyUser, useIsSharedUser } from '~/states/context';
+import {
+  useIsGuestUser,
+  useIsReadOnlyUser,
+  useIsSharedUser,
+} from '~/states/context';
 import { useDeleteAttachmentModalActions } from '~/states/ui/modal/delete-attachment';
 import { useDeleteAttachmentModalActions } from '~/states/ui/modal/delete-attachment';
 import { useSWRxAttachment } from '~/stores/attachment';
 import { useSWRxAttachment } from '~/stores/attachment';
 
 
 import styles from './RichAttachment.module.scss';
 import styles from './RichAttachment.module.scss';
 
 
 type RichAttachmentProps = {
 type RichAttachmentProps = {
-  attachmentId: string,
-  url: string,
-  attachmentName: string,
-}
+  attachmentId: string;
+  url: string;
+  attachmentName: string;
+};
 
 
 export const RichAttachment = React.memo((props: RichAttachmentProps) => {
 export const RichAttachment = React.memo((props: RichAttachmentProps) => {
   const { attachmentId, attachmentName } = props;
   const { attachmentId, attachmentName } = props;
@@ -27,7 +30,8 @@ export const RichAttachment = React.memo((props: RichAttachmentProps) => {
   const isSharedUser = useIsSharedUser();
   const isSharedUser = useIsSharedUser();
   const isReadOnlyUser = useIsReadOnlyUser();
   const isReadOnlyUser = useIsReadOnlyUser();
 
 
-  const showTrashButton = isGuestUser === false && isSharedUser === false && isReadOnlyUser === false;
+  const showTrashButton =
+    isGuestUser === false && isSharedUser === false && isReadOnlyUser === false;
 
 
   const onClickTrashButtonHandler = useCallback(() => {
   const onClickTrashButtonHandler = useCallback(() => {
     if (attachment == null) {
     if (attachment == null) {
@@ -37,7 +41,11 @@ export const RichAttachment = React.memo((props: RichAttachmentProps) => {
   }, [attachment, openDeleteAttachmentModal, remove]);
   }, [attachment, openDeleteAttachmentModal, remove]);
 
 
   if (attachment == null) {
   if (attachment == null) {
-    return <span className="text-muted">{t('rich_attachment.attachment_not_be_found')}</span>;
+    return (
+      <span className="text-muted">
+        {t('rich_attachment.attachment_not_be_found')}
+      </span>
+    );
   }
   }
 
 
   const {
   const {
@@ -50,18 +58,26 @@ export const RichAttachment = React.memo((props: RichAttachmentProps) => {
   } = attachment;
   } = attachment;
 
 
   // Guard here because attachment properties might be deleted in turn when an attachment is removed
   // Guard here because attachment properties might be deleted in turn when an attachment is removed
-  if (filePathProxied == null
-    || originalName == null
-    || downloadPathProxied == null
-    || creator == null
-    || createdAt == null
-    || fileSize == null
+  if (
+    filePathProxied == null ||
+    originalName == null ||
+    downloadPathProxied == null ||
+    creator == null ||
+    createdAt == null ||
+    fileSize == null
   ) {
   ) {
-    return <span className="text-muted">{t('rich_attachment.attachment_not_be_found')}</span>;
+    return (
+      <span className="text-muted">
+        {t('rich_attachment.attachment_not_be_found')}
+      </span>
+    );
   }
   }
 
 
   return (
   return (
-    <div data-testid="rich-attachment" className={`${styles.attachment} d-inline-block`}>
+    <div
+      data-testid="rich-attachment"
+      className={`${styles.attachment} d-inline-block`}
+    >
       <div className="my-2 p-2 card">
       <div className="my-2 p-2 card">
         <div className="p-1 card-body d-flex align-items-center">
         <div className="p-1 card-body d-flex align-items-center">
           <div className="me-2 px-0 d-flex align-items-center justify-content-center">
           <div className="me-2 px-0 d-flex align-items-center justify-content-center">
@@ -80,23 +96,33 @@ export const RichAttachment = React.memo((props: RichAttachmentProps) => {
               <a target="_blank" rel="noopener" href={filePathProxied}>
               <a target="_blank" rel="noopener" href={filePathProxied}>
                 {attachmentName || originalName}
                 {attachmentName || originalName}
               </a>
               </a>
-              <a className="ms-2 attachment-download" href={downloadPathProxied}>
-                <span className="material-symbols-outlined">cloud_download</span>
+              <a
+                className="ms-2 attachment-download"
+                href={downloadPathProxied}
+              >
+                <span className="material-symbols-outlined">
+                  cloud_download
+                </span>
               </a>
               </a>
 
 
               {showTrashButton && (
               {showTrashButton && (
-                <a className="ml-2 text-danger attachment-delete d-share-link-none" type="button" onClick={onClickTrashButtonHandler}>
+                <button
+                  type="button"
+                  className="ml-2 text-danger attachment-delete d-share-link-none border-0 bg-transparent p-0"
+                  onClick={onClickTrashButtonHandler}
+                >
                   <span className="material-symbols-outlined">delete</span>
                   <span className="material-symbols-outlined">delete</span>
-                </a>
+                </button>
               )}
               )}
-
             </div>
             </div>
             <div className="d-flex align-items-center">
             <div className="d-flex align-items-center">
               <UserPicture user={creator} size="sm" />
               <UserPicture user={creator} size="sm" />
               <span className="ms-2 text-muted">
               <span className="ms-2 text-muted">
                 {new Date(createdAt).toLocaleString('en-US')}
                 {new Date(createdAt).toLocaleString('en-US')}
               </span>
               </span>
-              <span className="ms-2 ps-2 border-start text-muted">{prettyBytes(fileSize)}</span>
+              <span className="ms-2 ps-2 border-start text-muted">
+                {prettyBytes(fileSize)}
+              </span>
             </div>
             </div>
           </div>
           </div>
         </div>
         </div>

+ 47 - 29
apps/app/src/client/components/ReactMarkdownComponents/TableWithEditButton.tsx

@@ -1,24 +1,28 @@
-import React, { useCallback, type JSX } from 'react';
-
+import React, { type JSX, useCallback } from 'react';
 import { globalEventTarget } from '@growi/core/dist/utils';
 import { globalEventTarget } from '@growi/core/dist/utils';
 import type { Element } from 'hast';
 import type { Element } from 'hast';
 
 
 import type { LaunchHandsonTableModalEventDetail } from '~/client/interfaces/handsontable-modal';
 import type { LaunchHandsonTableModalEventDetail } from '~/client/interfaces/handsontable-modal';
 import { useCurrentPageYjsData } from '~/features/collaborative-editor/states';
 import { useCurrentPageYjsData } from '~/features/collaborative-editor/states';
-import { useIsGuestUser, useIsReadOnlyUser, useIsSharedUser } from '~/states/context';
+import {
+  useIsGuestUser,
+  useIsReadOnlyUser,
+  useIsSharedUser,
+} from '~/states/context';
 import { useShareLinkId } from '~/states/page/hooks';
 import { useShareLinkId } from '~/states/page/hooks';
 import { useIsRevisionOutdated } from '~/stores/page';
 import { useIsRevisionOutdated } from '~/stores/page';
 
 
 import styles from './TableWithEditButton.module.scss';
 import styles from './TableWithEditButton.module.scss';
 
 
-
 type TableWithEditButtonProps = {
 type TableWithEditButtonProps = {
-  children: React.ReactNode,
-  node: Element,
-  className?: string
-}
+  children: React.ReactNode;
+  node: Element;
+  className?: string;
+};
 
 
-const TableWithEditButtonNoMemorized = (props: TableWithEditButtonProps): JSX.Element => {
+const TableWithEditButtonNoMemorized = (
+  props: TableWithEditButtonProps,
+): JSX.Element => {
   const { children, node, className } = props;
   const { children, node, className } = props;
 
 
   const isGuestUser = useIsGuestUser();
   const isGuestUser = useIsGuestUser();
@@ -32,34 +36,48 @@ const TableWithEditButtonNoMemorized = (props: TableWithEditButtonProps): JSX.El
   const eol = node.position?.end.line ?? 0;
   const eol = node.position?.end.line ?? 0;
 
 
   const editButtonClickHandler = useCallback(() => {
   const editButtonClickHandler = useCallback(() => {
-    globalEventTarget.dispatchEvent(new CustomEvent<LaunchHandsonTableModalEventDetail>('launchHandsonTableModal', {
-      detail: {
-        bol,
-        eol,
-      },
-    }));
+    globalEventTarget.dispatchEvent(
+      new CustomEvent<LaunchHandsonTableModalEventDetail>(
+        'launchHandsonTableModal',
+        {
+          detail: {
+            bol,
+            eol,
+          },
+        },
+      ),
+    );
   }, [bol, eol]);
   }, [bol, eol]);
 
 
-  const isNoEditingUsers = currentPageYjsData?.awarenessStateSize == null || currentPageYjsData?.awarenessStateSize === 0;
-  const showEditButton = isNoEditingUsers
-    && !isRevisionOutdated
-    && !isGuestUser
-    && !isReadOnlyUser
-    && !isSharedUser
-    && shareLinkId == null;
+  const isNoEditingUsers =
+    currentPageYjsData?.awarenessStateSize == null ||
+    currentPageYjsData?.awarenessStateSize === 0;
+  const showEditButton =
+    isNoEditingUsers &&
+    !isRevisionOutdated &&
+    !isGuestUser &&
+    !isReadOnlyUser &&
+    !isSharedUser &&
+    shareLinkId == null;
 
 
   return (
   return (
-    <div className={`editable-with-handsontable ${styles['editable-with-handsontable']}`}>
-      { showEditButton && (
-        <button type="button" className="handsontable-modal-trigger" onClick={editButtonClickHandler}>
+    <div
+      className={`editable-with-handsontable ${styles['editable-with-handsontable']}`}
+    >
+      {showEditButton && (
+        <button
+          type="button"
+          className="handsontable-modal-trigger"
+          onClick={editButtonClickHandler}
+        >
           <span className="material-symbols-outlined">edit_square</span>
           <span className="material-symbols-outlined">edit_square</span>
         </button>
         </button>
       )}
       )}
-      <table className={className}>
-        {children}
-      </table>
+      <table className={className}>{children}</table>
     </div>
     </div>
   );
   );
 };
 };
 TableWithEditButtonNoMemorized.displayName = 'TableWithEditButton';
 TableWithEditButtonNoMemorized.displayName = 'TableWithEditButton';
-export const TableWithEditButton = React.memo(TableWithEditButtonNoMemorized) as typeof TableWithEditButtonNoMemorized;
+export const TableWithEditButton = React.memo(
+  TableWithEditButtonNoMemorized,
+) as typeof TableWithEditButtonNoMemorized;

+ 9 - 1
apps/app/src/features/page-tree/hooks/_inner/use-scroll-to-selected-item.ts

@@ -1,4 +1,4 @@
-import { useEffect } from 'react';
+import { useEffect, useRef } from 'react';
 import type { Virtualizer } from '@tanstack/react-virtual';
 import type { Virtualizer } from '@tanstack/react-virtual';
 
 
 import type { IPageForTreeItem } from '~/interfaces/page';
 import type { IPageForTreeItem } from '~/interfaces/page';
@@ -14,7 +14,15 @@ export const useScrollToSelectedItem = ({
   items,
   items,
   virtualizer,
   virtualizer,
 }: UseScrollToSelectedItemParams): void => {
 }: UseScrollToSelectedItemParams): void => {
+  // Track the previous targetPathOrId to detect actual changes
+  const prevTargetPathOrIdRef = useRef<string | undefined>(undefined);
+
   useEffect(() => {
   useEffect(() => {
+    // Only scroll when targetPathOrId actually changes, not on items change alone
+    // This prevents unwanted scrolling when creating a new page (items update but targetPathOrId stays the same)
+    if (targetPathOrId === prevTargetPathOrIdRef.current) return;
+    prevTargetPathOrIdRef.current = targetPathOrId;
+
     if (targetPathOrId == null) return;
     if (targetPathOrId == null) return;
 
 
     const selectedIndex = items.findIndex((item) => {
     const selectedIndex = items.findIndex((item) => {

+ 1 - 6
biome.json

@@ -28,12 +28,7 @@
       "!apps/slackbot-proxy/src/public/bootstrap",
       "!apps/slackbot-proxy/src/public/bootstrap",
       "!packages/pdf-converter-client/src/index.ts",
       "!packages/pdf-converter-client/src/index.ts",
       "!packages/pdf-converter-client/specs",
       "!packages/pdf-converter-client/specs",
-      "!apps/app/src/client/components/Admin",
-      "!apps/app/src/client/components/Bookmarks",
-      "!apps/app/src/client/components/InAppNotification",
-      "!apps/app/src/client/components/Me",
-      "!apps/app/src/client/components/PageTags",
-      "!apps/app/src/client/components/ReactMarkdownComponents"
+      "!apps/app/src/client/components/Admin"
     ]
     ]
   },
   },
   "formatter": {
   "formatter": {

+ 1 - 1
packages/slack/package.json

@@ -61,7 +61,7 @@
     "date-fns": "^3.6.0",
     "date-fns": "^3.6.0",
     "extensible-custom-error": "^0.0.7",
     "extensible-custom-error": "^0.0.7",
     "http-errors": "^2.0.0",
     "http-errors": "^2.0.0",
-    "qs": "^6.10.2",
+    "qs": "^6.14.1",
     "universal-bunyan": "^0.9.2",
     "universal-bunyan": "^0.9.2",
     "url-join": "^4.0.0"
     "url-join": "^4.0.0"
   },
   },

+ 94 - 19
pnpm-lock.yaml

@@ -596,8 +596,8 @@ importers:
         specifier: ^15.8.1
         specifier: ^15.8.1
         version: 15.8.1
         version: 15.8.1
       qs:
       qs:
-        specifier: ^6.11.1
-        version: 6.13.0
+        specifier: ^6.14.1
+        version: 6.14.1
       rate-limiter-flexible:
       rate-limiter-flexible:
         specifier: ^2.3.7
         specifier: ^2.3.7
         version: 2.4.2
         version: 2.4.2
@@ -1864,8 +1864,8 @@ importers:
         specifier: ^2.0.0
         specifier: ^2.0.0
         version: 2.0.1
         version: 2.0.1
       qs:
       qs:
-        specifier: ^6.10.2
-        version: 6.13.0
+        specifier: ^6.14.1
+        version: 6.14.1
       universal-bunyan:
       universal-bunyan:
         specifier: ^0.9.2
         specifier: ^0.9.2
         version: 0.9.2(@browser-bunyan/console-formatted-stream@1.8.0)(browser-bunyan@1.8.0)(bunyan@1.8.15)
         version: 0.9.2(@browser-bunyan/console-formatted-stream@1.8.0)(browser-bunyan@1.8.0)(bunyan@1.8.15)
@@ -2752,6 +2752,9 @@ packages:
   '@codemirror/view@6.39.7':
   '@codemirror/view@6.39.7':
     resolution: {integrity: sha512-3Vif9hnNHJnl2YgOtkR/wzGzhYcQ8gy3LGdUhkLUU8xSBbgsTxrE8he/CMTpeINm5TgxLe2FmzvF6IYQL/BSAg==}
     resolution: {integrity: sha512-3Vif9hnNHJnl2YgOtkR/wzGzhYcQ8gy3LGdUhkLUU8xSBbgsTxrE8he/CMTpeINm5TgxLe2FmzvF6IYQL/BSAg==}
 
 
+  '@codemirror/view@6.39.8':
+    resolution: {integrity: sha512-1rASYd9Z/mE3tkbC9wInRlCNyCkSn+nLsiQKZhEDUUJiUfs/5FHDpCUDaQpoTIaNGeDc6/bhaEAyLmeEucEFPw==}
+
   '@colors/colors@1.5.0':
   '@colors/colors@1.5.0':
     resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
     resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
     engines: {node: '>=0.1.90'}
     engines: {node: '>=0.1.90'}
@@ -6959,6 +6962,10 @@ packages:
     resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==}
     resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==}
     engines: {node: '>= 0.4'}
     engines: {node: '>= 0.4'}
 
 
+  call-bound@1.0.4:
+    resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
+    engines: {node: '>= 0.4'}
+
   call-me-maybe@1.0.2:
   call-me-maybe@1.0.2:
     resolution: {integrity: sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==}
     resolution: {integrity: sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==}
 
 
@@ -8905,6 +8912,10 @@ packages:
     resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==}
     resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==}
     engines: {node: '>=0.10'}
     engines: {node: '>=0.10'}
 
 
+  esquery@1.7.0:
+    resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==}
+    engines: {node: '>=0.10'}
+
   esrecurse@4.3.0:
   esrecurse@4.3.0:
     resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
     resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
     engines: {node: '>=4.0'}
     engines: {node: '>=4.0'}
@@ -12006,6 +12017,10 @@ packages:
   object-inspect@1.13.1:
   object-inspect@1.13.1:
     resolution: {integrity: sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==}
     resolution: {integrity: sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==}
 
 
+  object-inspect@1.13.4:
+    resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
+    engines: {node: '>= 0.4'}
+
   object-keys@0.4.0:
   object-keys@0.4.0:
     resolution: {integrity: sha512-ncrLw+X55z7bkl5PnUvHwFK9FcGuFYo9gtjws2XtSzL+aZ8tm830P60WJ0dSmFVaSalWieW5MD7kEdnXda9yJw==}
     resolution: {integrity: sha512-ncrLw+X55z7bkl5PnUvHwFK9FcGuFYo9gtjws2XtSzL+aZ8tm830P60WJ0dSmFVaSalWieW5MD7kEdnXda9yJw==}
 
 
@@ -12718,8 +12733,12 @@ packages:
     resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==}
     resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==}
     engines: {node: '>=0.6'}
     engines: {node: '>=0.6'}
 
 
-  qs@6.5.2:
-    resolution: {integrity: sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==}
+  qs@6.14.1:
+    resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==}
+    engines: {node: '>=0.6'}
+
+  qs@6.5.3:
+    resolution: {integrity: sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==}
     engines: {node: '>=0.6'}
     engines: {node: '>=0.6'}
 
 
   quansync@0.2.10:
   quansync@0.2.10:
@@ -13617,8 +13636,20 @@ packages:
     engines: {node: '>=6'}
     engines: {node: '>=6'}
     hasBin: true
     hasBin: true
 
 
-  side-channel@1.0.6:
-    resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==}
+  side-channel-list@1.0.0:
+    resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
+    engines: {node: '>= 0.4'}
+
+  side-channel-map@1.0.1:
+    resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
+    engines: {node: '>= 0.4'}
+
+  side-channel-weakmap@1.0.2:
+    resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
+    engines: {node: '>= 0.4'}
+
+  side-channel@1.1.0:
+    resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
     engines: {node: '>= 0.4'}
     engines: {node: '>= 0.4'}
 
 
   sift@16.0.1:
   sift@16.0.1:
@@ -15247,6 +15278,7 @@ packages:
   whatwg-encoding@3.1.1:
   whatwg-encoding@3.1.1:
     resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
     resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
     engines: {node: '>=18'}
     engines: {node: '>=18'}
+    deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
 
 
   whatwg-mimetype@3.0.0:
   whatwg-mimetype@3.0.0:
     resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
     resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
@@ -17420,7 +17452,7 @@ snapshots:
     dependencies:
     dependencies:
       '@codemirror/language': 6.12.1
       '@codemirror/language': 6.12.1
       '@codemirror/state': 6.5.3
       '@codemirror/state': 6.5.3
-      '@codemirror/view': 6.39.7
+      '@codemirror/view': 6.39.8
       '@lezer/highlight': 1.2.3
       '@lezer/highlight': 1.2.3
 
 
   '@codemirror/view@6.39.7':
   '@codemirror/view@6.39.7':
@@ -17430,6 +17462,13 @@ snapshots:
       style-mod: 4.1.3
       style-mod: 4.1.3
       w3c-keyname: 2.2.8
       w3c-keyname: 2.2.8
 
 
+  '@codemirror/view@6.39.8':
+    dependencies:
+      '@codemirror/state': 6.5.3
+      crelt: 1.0.6
+      style-mod: 4.1.3
+      w3c-keyname: 2.2.8
+
   '@colors/colors@1.5.0':
   '@colors/colors@1.5.0':
     optional: true
     optional: true
 
 
@@ -23059,6 +23098,11 @@ snapshots:
       get-intrinsic: 1.3.0
       get-intrinsic: 1.3.0
       set-function-length: 1.2.2
       set-function-length: 1.2.2
 
 
+  call-bound@1.0.4:
+    dependencies:
+      call-bind-apply-helpers: 1.0.2
+      get-intrinsic: 1.3.0
+
   call-me-maybe@1.0.2: {}
   call-me-maybe@1.0.2: {}
 
 
   callsites@3.0.0: {}
   callsites@3.0.0: {}
@@ -24955,6 +24999,10 @@ snapshots:
     dependencies:
     dependencies:
       estraverse: 5.3.0
       estraverse: 5.3.0
 
 
+  esquery@1.7.0:
+    dependencies:
+      estraverse: 5.3.0
+
   esrecurse@4.3.0:
   esrecurse@4.3.0:
     dependencies:
     dependencies:
       estraverse: 5.3.0
       estraverse: 5.3.0
@@ -26299,7 +26347,7 @@ snapshots:
     dependencies:
     dependencies:
       es-errors: 1.3.0
       es-errors: 1.3.0
       hasown: 2.0.2
       hasown: 2.0.2
-      side-channel: 1.0.6
+      side-channel: 1.1.0
 
 
   internmap@1.0.1: {}
   internmap@1.0.1: {}
 
 
@@ -28851,6 +28899,8 @@ snapshots:
 
 
   object-inspect@1.13.1: {}
   object-inspect@1.13.1: {}
 
 
+  object-inspect@1.13.4: {}
+
   object-keys@0.4.0: {}
   object-keys@0.4.0: {}
 
 
   object-keys@1.1.1: {}
   object-keys@1.1.1: {}
@@ -29628,9 +29678,13 @@ snapshots:
 
 
   qs@6.13.0:
   qs@6.13.0:
     dependencies:
     dependencies:
-      side-channel: 1.0.6
+      side-channel: 1.1.0
+
+  qs@6.14.1:
+    dependencies:
+      side-channel: 1.1.0
 
 
-  qs@6.5.2: {}
+  qs@6.5.3: {}
 
 
   quansync@0.2.10: {}
   quansync@0.2.10: {}
 
 
@@ -30411,7 +30465,7 @@ snapshots:
       mime-types: 2.1.35
       mime-types: 2.1.35
       oauth-sign: 0.9.0
       oauth-sign: 0.9.0
       performance-now: 2.1.0
       performance-now: 2.1.0
-      qs: 6.5.2
+      qs: 6.5.3
       safe-buffer: 5.2.1
       safe-buffer: 5.2.1
       tough-cookie: 2.5.0
       tough-cookie: 2.5.0
       tunnel-agent: 0.6.0
       tunnel-agent: 0.6.0
@@ -30824,12 +30878,33 @@ snapshots:
       minimist: 1.2.8
       minimist: 1.2.8
       shelljs: 0.8.5
       shelljs: 0.8.5
 
 
-  side-channel@1.0.6:
+  side-channel-list@1.0.0:
     dependencies:
     dependencies:
-      call-bind: 1.0.7
+      es-errors: 1.3.0
+      object-inspect: 1.13.4
+
+  side-channel-map@1.0.1:
+    dependencies:
+      call-bound: 1.0.4
       es-errors: 1.3.0
       es-errors: 1.3.0
       get-intrinsic: 1.3.0
       get-intrinsic: 1.3.0
-      object-inspect: 1.13.1
+      object-inspect: 1.13.4
+
+  side-channel-weakmap@1.0.2:
+    dependencies:
+      call-bound: 1.0.4
+      es-errors: 1.3.0
+      get-intrinsic: 1.3.0
+      object-inspect: 1.13.4
+      side-channel-map: 1.0.1
+
+  side-channel@1.1.0:
+    dependencies:
+      es-errors: 1.3.0
+      object-inspect: 1.13.4
+      side-channel-list: 1.0.0
+      side-channel-map: 1.0.1
+      side-channel-weakmap: 1.0.2
 
 
   sift@16.0.1: {}
   sift@16.0.1: {}
 
 
@@ -31186,7 +31261,7 @@ snapshots:
       has-symbols: 1.1.0
       has-symbols: 1.1.0
       internal-slot: 1.0.7
       internal-slot: 1.0.7
       regexp.prototype.flags: 1.5.2
       regexp.prototype.flags: 1.5.2
-      side-channel: 1.0.6
+      side-channel: 1.1.0
 
 
   string.prototype.padend@3.0.0:
   string.prototype.padend@3.0.0:
     dependencies:
     dependencies:
@@ -31391,7 +31466,7 @@ snapshots:
       formidable: 3.5.4
       formidable: 3.5.4
       methods: 1.1.2
       methods: 1.1.2
       mime: 2.6.0
       mime: 2.6.0
-      qs: 6.13.0
+      qs: 6.14.1
     transitivePeerDependencies:
     transitivePeerDependencies:
       - supports-color
       - supports-color
 
 
@@ -32548,7 +32623,7 @@ snapshots:
       eslint-scope: 5.1.1
       eslint-scope: 5.1.1
       eslint-visitor-keys: 1.3.0
       eslint-visitor-keys: 1.3.0
       espree: 6.2.1
       espree: 6.2.1
-      esquery: 1.6.0
+      esquery: 1.7.0
       lodash: 4.17.21
       lodash: 4.17.21
       semver: 6.3.1
       semver: 6.3.1
     transitivePeerDependencies:
     transitivePeerDependencies: