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

Merge remote-tracking branch 'origin/master' into support/156162-176218-app-some-client-components-biome-7

Yuki Takei 3 месяцев назад
Родитель
Сommit
9559adeba2
94 измененных файлов с 4159 добавлено и 2586 удалено
  1. 19 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. 4 4
      apps/app/src/client/components/DescendantsPageListModal/DescendantsPageListModal.spec.tsx
  14. 72 36
      apps/app/src/client/components/DescendantsPageListModal/DescendantsPageListModal.tsx
  15. 4 1
      apps/app/src/client/components/DescendantsPageListModal/dynamic.tsx
  16. 51 31
      apps/app/src/client/components/InAppNotification/InAppNotificationDropdown.tsx
  17. 14 12
      apps/app/src/client/components/InAppNotification/InAppNotificationElm.tsx
  18. 8 9
      apps/app/src/client/components/InAppNotification/InAppNotificationList.tsx
  19. 105 80
      apps/app/src/client/components/InAppNotification/InAppNotificationPage.tsx
  20. 7 9
      apps/app/src/client/components/InAppNotification/ModelNotification/ModelNotification.tsx
  21. 42 20
      apps/app/src/client/components/InAppNotification/ModelNotification/PageBulkExportJobModelNotification.tsx
  22. 15 14
      apps/app/src/client/components/InAppNotification/ModelNotification/PageModelNotification.tsx
  23. 10 10
      apps/app/src/client/components/InAppNotification/ModelNotification/UserModelNotification.tsx
  24. 13 11
      apps/app/src/client/components/InAppNotification/ModelNotification/index.tsx
  25. 6 4
      apps/app/src/client/components/InAppNotification/ModelNotification/useActionAndMsg.ts
  26. 0 2
      apps/app/src/client/components/ItemsTree/ItemsTreeContentSkeleton.tsx
  27. 21 9
      apps/app/src/client/components/LoginForm/ExternalAuthButton.tsx
  28. 34 20
      apps/app/src/client/components/LoginForm/LoginForm.spec.tsx
  29. 299 183
      apps/app/src/client/components/LoginForm/LoginForm.tsx
  30. 130 112
      apps/app/src/client/components/Me/AccessTokenForm.tsx
  31. 76 68
      apps/app/src/client/components/Me/AccessTokenList.tsx
  32. 16 8
      apps/app/src/client/components/Me/AccessTokenScopeList.tsx
  33. 20 6
      apps/app/src/client/components/Me/AccessTokenScopeSelect.tsx
  34. 107 82
      apps/app/src/client/components/Me/AccessTokenSettings.tsx
  35. 8 6
      apps/app/src/client/components/Me/ApiSettings.tsx
  36. 34 39
      apps/app/src/client/components/Me/ApiTokenSettings.tsx
  37. 59 35
      apps/app/src/client/components/Me/AssociateModal.tsx
  38. 110 57
      apps/app/src/client/components/Me/BasicInfoSettings.tsx
  39. 50 24
      apps/app/src/client/components/Me/ColorModeSettings.tsx
  40. 51 28
      apps/app/src/client/components/Me/DisassociateModal.tsx
  41. 1 2
      apps/app/src/client/components/Me/EditorSettings.tsx
  42. 29 23
      apps/app/src/client/components/Me/ExternalAccountLinkedMe.jsx
  43. 3 5
      apps/app/src/client/components/Me/ExternalAccountRow.jsx
  44. 54 32
      apps/app/src/client/components/Me/InAppNotificationSettings.tsx
  45. 0 2
      apps/app/src/client/components/Me/OtherSettings.tsx
  46. 71 35
      apps/app/src/client/components/Me/PasswordSettings.jsx
  47. 69 13
      apps/app/src/client/components/Me/PersonalSettings.jsx
  48. 128 59
      apps/app/src/client/components/Me/ProfileImageSettings.tsx
  49. 66 30
      apps/app/src/client/components/Me/UISettings.tsx
  50. 3 2
      apps/app/src/client/components/Me/UserSettings.tsx
  51. 16 11
      apps/app/src/client/components/Page/DisplaySwitcher.tsx
  52. 5 4
      apps/app/src/client/components/Page/EditablePageEffects.tsx
  53. 7 4
      apps/app/src/client/components/Page/PageContentsUtilities.tsx
  54. 18 21
      apps/app/src/client/components/Page/RevisionLoader.tsx
  55. 2 5
      apps/app/src/client/components/Page/SlideRenderer.tsx
  56. 6 1
      apps/app/src/client/components/Page/markdown-drawio-util-for-view.ts
  57. 17 5
      apps/app/src/client/components/Page/markdown-table-util-for-view.ts
  58. 31 30
      apps/app/src/client/components/PageAttachment/DeleteAttachmentModal.tsx
  59. 7 10
      apps/app/src/client/components/PageAttachment/PageAttachmentList.tsx
  60. 4 1
      apps/app/src/client/components/PageAttachment/dynamic.tsx
  61. 231 138
      apps/app/src/client/components/PageDeleteModal/PageDeleteModal.tsx
  62. 4 1
      apps/app/src/client/components/PageDeleteModal/dynamic.tsx
  63. 144 84
      apps/app/src/client/components/PageDuplicateModal/PageDuplicateModal.tsx
  64. 4 1
      apps/app/src/client/components/PageDuplicateModal/dynamic.tsx
  65. 15 14
      apps/app/src/client/components/PageList/PageList.tsx
  66. 212 134
      apps/app/src/client/components/PageList/PageListItemL.tsx
  67. 23 19
      apps/app/src/client/components/PageList/PageListItemS.tsx
  68. 49 29
      apps/app/src/client/components/PageManagement/ApiErrorMessage.jsx
  69. 10 5
      apps/app/src/client/components/PageManagement/ApiErrorMessageList.jsx
  70. 24 11
      apps/app/src/client/components/PagePathNavSticky/CollapsedParentsDropdown.tsx
  71. 56 27
      apps/app/src/client/components/PagePathNavSticky/PagePathNavSticky.tsx
  72. 26 22
      apps/app/src/client/components/PagePresentationModal/PagePresentationModal.tsx
  73. 4 1
      apps/app/src/client/components/PagePresentationModal/dynamic.tsx
  74. 181 96
      apps/app/src/client/components/PageRenameModal/PageRenameModal.tsx
  75. 4 1
      apps/app/src/client/components/PageRenameModal/dynamic.tsx
  76. 19 17
      apps/app/src/client/components/PageSelectModal/PageSelectModal.tsx
  77. 8 11
      apps/app/src/client/components/PageSelectModal/TreeItemForModal.tsx
  78. 4 1
      apps/app/src/client/components/PageSelectModal/dynamic.tsx
  79. 14 18
      apps/app/src/client/components/PageSideContents/PageAccessoriesControl.tsx
  80. 50 32
      apps/app/src/client/components/PageSideContents/PageSideContents.tsx
  81. 15 15
      apps/app/src/client/components/PageTags/PageTags.tsx
  82. 5 7
      apps/app/src/client/components/PageTags/RenderTagLabels.tsx
  83. 42 24
      apps/app/src/client/components/PageTags/TagEditModal/TagEditModal.tsx
  84. 26 16
      apps/app/src/client/components/PageTags/TagEditModal/TagsInput.tsx
  85. 2 1
      apps/app/src/client/components/PageTags/TagEditModal/dynamic.tsx
  86. 74 55
      apps/app/src/client/components/ReactMarkdownComponents/DrawioViewerWithEditButton.tsx
  87. 73 45
      apps/app/src/client/components/ReactMarkdownComponents/Header.tsx
  88. 14 4
      apps/app/src/client/components/ReactMarkdownComponents/LightBox.tsx
  89. 49 23
      apps/app/src/client/components/ReactMarkdownComponents/RichAttachment.tsx
  90. 47 29
      apps/app/src/client/components/ReactMarkdownComponents/TableWithEditButton.tsx
  91. 9 1
      apps/app/src/features/page-tree/hooks/_inner/use-scroll-to-selected-item.ts
  92. 1 20
      biome.json
  93. 1 1
      packages/slack/package.json
  94. 94 19
      pnpm-lock.yaml

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

@@ -37,6 +37,20 @@ module.exports = {
     'src/interfaces/**',
     'src/utils/**',
     'src/components/**',
+    'src/client/components/DescendantsPageListModal/**',
+    'src/client/components/ItemsTree/**',
+    'src/client/components/LoginForm/**',
+    'src/client/components/Page/**',
+    'src/client/components/PageAttachment/**',
+    'src/client/components/PageDeleteModal/**',
+    'src/client/components/PageDuplicateModal/**',
+    'src/client/components/PageList/**',
+    'src/client/components/PageManagement/**',
+    'src/client/components/PagePathNavSticky/**',
+    'src/client/components/PagePresentationModal/**',
+    'src/client/components/PageRenameModal/**',
+    'src/client/components/PageSelectModal/**',
+    'src/client/components/PageSideContents/**',
     'src/client/components/*.tsx',
     'src/client/components/*.jsx',
     'src/client/components/*.ts',
@@ -50,6 +64,11 @@ module.exports = {
     'src/client/components/Admin/Users/**',
     'src/client/components/Admin/UserGroup/**',
     'src/client/components/Admin/UserGroupDetail/**',
+    '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/Common/**',
     'src/client/components/CreateTemplateModal/**',

+ 1 - 1
apps/app/package.json

@@ -193,7 +193,7 @@
     "passport-saml": "^3.2.0",
     "pathe": "^2.0.3",
     "prop-types": "^15.8.1",
-    "qs": "^6.11.1",
+    "qs": "^6.14.1",
     "rate-limiter-flexible": "^2.3.7",
     "react": "^18.2.0",
     "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 { useCallback, useState } from 'react';
-
 import type { IPageToDeleteWithMeta } from '@growi/core';
 import { DropdownToggle } from 'reactstrap';
 
 import { FolderIcon } from '~/client/components/Icons/FolderIcon';
 import {
-  addBookmarkToFolder, addNewFolder, hasChildren, updateBookmarkFolder,
+  addBookmarkToFolder,
+  addNewFolder,
+  hasChildren,
+  updateBookmarkFolder,
 } from '~/client/util/bookmark-utils';
 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 type { onDeletedBookmarkFolderFunction } from '~/interfaces/ui';
 import { useDeleteBookmarkFolderModalActions } from '~/states/ui/modal/delete-bookmark-folder';
@@ -20,28 +26,43 @@ import { BookmarkItem } from './BookmarkItem';
 import { DragAndDropWrapper } from './DragAndDropWrapper';
 
 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 acceptedTypes: DragItemType[] = [DRAG_ITEM_TYPE.FOLDER, DRAG_ITEM_TYPE.BOOKMARK];
+  const acceptedTypes: DragItemType[] = [
+    DRAG_ITEM_TYPE.FOLDER,
+    DRAG_ITEM_TYPE.BOOKMARK,
+  ];
   const {
-    isReadOnlyUser, bookmarkFolder, isOpen: _isOpen = false, isOperable, level, root, isUserHomepage,
-    onClickDeleteMenuItemHandler, bookmarkFolderTreeMutation,
+    isReadOnlyUser,
+    bookmarkFolder,
+    isOpen: _isOpen = false,
+    isOperable,
+    level,
+    root,
+    isUserHomepage,
+    onClickDeleteMenuItemHandler,
+    bookmarkFolderTreeMutation,
   } = props;
 
   const {
-    name, _id: folderId, childFolder, parent, bookmarks,
+    name,
+    _id: folderId,
+    childFolder,
+    parent,
+    bookmarks,
   } = bookmarkFolder;
 
   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 [isCreateAction, setIsCreateAction] = useState<boolean>(false);
 
-  const { open: openDeleteBookmarkFolderModal } = useDeleteBookmarkFolderModalActions();
+  const { open: openDeleteBookmarkFolderModal } =
+    useDeleteBookmarkFolderModalActions();
 
   const childrenExists = hasChildren({ childFolder, bookmarks });
 
   const paddingLeft = BASE_FOLDER_PADDING * level;
 
-  const loadChildFolder = useCallback(async() => {
+  const loadChildFolder = useCallback(async () => {
     setIsOpen(!isOpen);
     setTargetFolder(folderId);
   }, [folderId, isOpen]);
@@ -66,95 +88,127 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   }, []);
 
   // 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
-  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) {
       try {
         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();
         }
-      }
-      catch (err) {
+      } catch (err) {
         toastError(err);
       }
-    }
-    else {
+    } else {
       try {
         if (item != null) {
           await addBookmarkToFolder(item._id, bookmarkFolder._id);
           bookmarkFolderTreeMutation();
         }
-      }
-      catch (err) {
+      } catch (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 (item.bookmarkFolder.parent === bookmarkFolder._id || item.bookmarkFolder._id === bookmarkFolder._id) {
+      if (
+        item.bookmarkFolder.parent === bookmarkFolder._id ||
+        item.bookmarkFolder._id === bookmarkFolder._id
+      ) {
         return false;
       }
 
       // 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 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 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 true;
   };
 
-  const triangleBtnClassName = (isOpen: boolean, childrenExists: boolean): string => {
+  const triangleBtnClassName = (
+    isOpen: boolean,
+    childrenExists: boolean,
+  ): string => {
     if (!childrenExists) {
       return 'grw-foldertree-triangle-btn btn px-0 opacity-25';
     }
@@ -162,41 +216,47 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   };
 
   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}
             isOperable={props.isOperable}
-            bookmarkFolder={childFolder}
+            bookmarkedPage={bookmark.page}
             level={level + 1}
-            root={root}
-            isUserHomepage={isUserHomepage}
+            parentFolder={bookmarkFolder}
+            canMoveToRoot
             onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
             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(() => {
@@ -204,7 +264,9 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   }, []);
 
   const onClickDeleteHandler = useCallback(() => {
-    const bookmarkFolderDeleteHandler: onDeletedBookmarkFolderFunction = (folderId) => {
+    const bookmarkFolderDeleteHandler: onDeletedBookmarkFolderFunction = (
+      folderId,
+    ) => {
       if (typeof folderId !== 'string') {
         return;
       }
@@ -214,21 +276,39 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
     if (bookmarkFolder == null) {
       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 (
-    <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
         key={folderId}
         type={acceptedTypes}
@@ -240,23 +320,8 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
       >
         <li
           className="list-group-item list-group-item-action border-0 py-2 d-flex align-items-center rounded-1"
-          onClick={loadChildFolder}
           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 ? (
             <div className="flex-fill">
               <BookmarkFolderNameInput
@@ -266,27 +331,46 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
               />
             </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">
                 <p className="text-truncate m-auto">{name}</p>
               </div>
-            </>
+            </button>
           )}
           {isOperable && (
             <div className="grw-foldertree-control d-flex">
               <BookmarkFolderItemControl
                 onClickRename={onClickRenameHandler}
                 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>
               {/* Maximum folder hierarchy of 2 levels */}
               {!(bookmarkFolder.parent != null) && (
@@ -304,17 +388,10 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
         </li>
       </DragAndDropWrapper>
       {isCreateAction && (
-        <BookmarkFolderNameInput
-          onSubmit={create}
-          onCancel={cancel}
-        />
+        <BookmarkFolderNameInput onSubmit={create} onCancel={cancel} />
       )}
-      {
-        renderChildFolder()
-      }
-      {
-        renderBookmarkItem()
-      }
+      {renderChildFolder()}
+      {renderBookmarkItem()}
     </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 {
-  Dropdown, DropdownItem, DropdownMenu, DropdownToggle,
+  Dropdown,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
 } from 'reactstrap';
 
 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,
   onClickMoveToRoot,
@@ -21,23 +23,25 @@ export const BookmarkFolderItemControl: React.FC<{
 
   return (
     <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>
         </DropdownToggle>
-      ) }
+      )}
 
-      { isOpen && (
-        <DropdownMenu
-          container="body"
-          style={{ zIndex: 1055 }}
-        >
+      {isOpen && (
+        <DropdownMenu container="body" style={{ zIndex: 1055 }}>
           {onClickMoveToRoot && (
             <DropdownItem
               onClick={onClickMoveToRoot}
               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')}
             </DropdownItem>
           )}
@@ -45,7 +49,9 @@ export const BookmarkFolderItemControl: React.FC<{
             onClick={onClickRename}
             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')}
           </DropdownItem>
 
@@ -55,11 +61,13 @@ export const BookmarkFolderItemControl: React.FC<{
             className="pt-2 grw-page-control-dropdown-item text-danger"
             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')}
           </DropdownItem>
         </DropdownMenu>
-      ) }
+      )}
     </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 { 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 { useCurrentUser } from '~/states/global';
 import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
@@ -17,43 +17,45 @@ import { BookmarkFolderMenuItem } from './BookmarkFolderMenuItem';
 import styles from './BookmarkFolderMenu.module.scss';
 
 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 [selectedItem, setSelectedItem] = useState<string | null>(null);
 
   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 isBookmarkFolderExists = useMemo((): boolean => {
     return bookmarkFolders != null && bookmarkFolders.length > 0;
   }, [bookmarkFolders]);
 
-  const toggleBookmarkHandler = useCallback(async() => {
+  const toggleBookmarkHandler = useCallback(async () => {
     try {
       await toggleBookmark(pageId, isBookmarked);
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
     }
   }, [isBookmarked, pageId]);
 
-  const onUnbookmarkHandler = useCallback(async() => {
+  const onUnbookmarkHandler = useCallback(async () => {
     if (onUnbookmark != null) {
       onUnbookmark();
     }
@@ -62,9 +64,15 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
     mutateCurrentUserBookmarks();
     mutateBookmarkFolders();
     mutatePageInfo();
-  }, [onUnbookmark, toggleBookmarkHandler, mutateCurrentUserBookmarks, mutateBookmarkFolders, mutatePageInfo]);
-
-  const toggleHandler = useCallback(async() => {
+  }, [
+    onUnbookmark,
+    toggleBookmarkHandler,
+    mutateCurrentUserBookmarks,
+    mutateBookmarkFolders,
+    mutatePageInfo,
+  ]);
+
+  const toggleHandler = useCallback(async () => {
     // on close
     if (isOpen && bookmarkFolders != null) {
       bookmarkFolders.forEach((bookmarkFolder) => {
@@ -89,29 +97,48 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
         await toggleBookmarkHandler();
         mutateCurrentUserBookmarks();
         mutatePageInfo();
-      }
-      catch (err) {
+      } catch (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 = () => {
     return (
@@ -122,9 +149,7 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
           className="grw-bookmark-folder-menu-item text-danger text-truncate"
         >
           <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>
 
         {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"
                 tabIndex={0}
                 role="menuitem"
-                onClick={e => onMenuItemClickHandler(e, 'root')}
+                onClick={(e) => onMenuItemClickHandler(e, 'root')}
+                onKeyDown={onMenuItemKeyDownHandler('root')}
               >
                 <BookmarkFolderMenuItem
                   itemId="root"
@@ -144,13 +170,14 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
                 />
               </div>
             </div>
-            {bookmarkFolders?.map(folder => (
+            {bookmarkFolders?.map((folder) => (
               <React.Fragment key={`bookmark-folders-${folder._id}`}>
                 <div
                   className="dropdown-item grw-bookmark-folder-menu-item grw-bookmark-folder-menu-item-folder-first list-group-item list-group-item-action"
                   tabIndex={0}
                   role="menuitem"
-                  onClick={e => onMenuItemClickHandler(e, folder._id)}
+                  onClick={(e) => onMenuItemClickHandler(e, folder._id)}
+                  onKeyDown={onMenuItemKeyDownHandler(folder._id)}
                 >
                   <BookmarkFolderMenuItem
                     itemId={folder._id}
@@ -158,13 +185,14 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
                     isSelected={selectedItem === folder._id}
                   />
                 </div>
-                {folder.childFolder?.map(child => (
+                {folder.childFolder?.map((child) => (
                   <div key={child._id}>
                     <div
                       className="dropdown-item grw-bookmark-folder-menu-item grw-bookmark-folder-menu-item-folder-second list-group-item list-group-item-action"
                       tabIndex={0}
                       role="menuitem"
-                      onClick={e => onMenuItemClickHandler(e, child._id)}
+                      onClick={(e) => onMenuItemClickHandler(e, child._id)}
+                      onKeyDown={onMenuItemKeyDownHandler(child._id)}
                     >
                       <BookmarkFolderMenuItem
                         itemId={child._id}
@@ -183,13 +211,10 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
   };
 
   return (
-    <UncontrolledDropdown
-      isOpen={isOpen}
-      onToggle={toggleHandler}
-    >
+    <UncontrolledDropdown isOpen={isOpen} onToggle={toggleHandler}>
       {children}
 
-      { isOpen && (
+      {isOpen && (
         <DropdownMenu
           end
           persist
@@ -197,9 +222,9 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
           container="body"
           className={`grw-bookmark-folder-menu ${styles['grw-bookmark-folder-menu']}`}
         >
-          { renderBookmarkMenuItem() }
+          {renderBookmarkMenuItem()}
         </DropdownMenu>
-      ) }
+      )}
     </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<{
-  itemId: string
-  itemName: string
-  isSelected: boolean
-}> = ({
-  itemId,
-  itemName,
-  isSelected,
-}) => {
+  itemId: string;
+  itemName: string;
+  isSelected: boolean;
+}> = ({ itemId, itemName, isSelected }) => {
   return (
     <div className="d-flex align-items-center grw-bookmark-folder-menu-item-title">
       <input
@@ -16,10 +12,13 @@ export const BookmarkFolderMenuItem: React.FC<{
         checked={isSelected}
         name="bookmark-folder-menu-item"
         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}
       </label>
     </div>

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

@@ -1,19 +1,26 @@
 import type { ChangeEvent, JSX } from 'react';
 import { useCallback, useRef, useState } from 'react';
-
 import { useRect } from '@growi/ui/dist/utils';
 import { useTranslation } from 'next-i18next';
 import type { AutosizeInputProps } from 'react-input-autosize';
 import { debounce } from 'throttle-debounce';
 
 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';
 
-
-type Props = Pick<SubmittableInputProps<AutosizeInputProps>, 'value' | 'onSubmit' | 'onCancel'>;
+type Props = Pick<
+  SubmittableInputProps<AutosizeInputProps>,
+  'value' | 'onSubmit' | 'onCancel'
+>;
 
 export const BookmarkFolderNameInput = (props: Props): JSX.Element => {
   const { t } = useTranslation();
@@ -23,15 +30,18 @@ export const BookmarkFolderNameInput = (props: Props): JSX.Element => {
   const parentRef = useRef<HTMLDivElement>(null);
   const [parentRect] = useRect(parentRef);
 
-  const [validationResult, setValidationResult] = useState<InputValidationResult>();
-
+  const [validationResult, setValidationResult] =
+    useState<InputValidationResult>();
 
   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 cancelHandler = useCallback(() => {
@@ -41,9 +51,14 @@ export const BookmarkFolderNameInput = (props: Props): JSX.Element => {
 
   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 (
     <div ref={parentRef}>
@@ -52,17 +67,22 @@ export const BookmarkFolderNameInput = (props: Props): JSX.Element => {
         inputClassName={`form-control ${isInvalid ? 'is-invalid' : ''}`}
         inputStyle={{ maxWidth }}
         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
         onChange={changeHandlerDebounced}
         onSubmit={onSubmit}
         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}
         </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 { useTranslation } from 'next-i18next';
-import { useRouter } from 'next/router';
 import { DndProvider } from 'react-dnd';
 import { HTML5Backend } from 'react-dnd-html5-backend';
 
@@ -13,7 +12,8 @@ import { useIsReadOnlyUser } from '~/states/context';
 import { useCurrentPageData } from '~/states/page';
 import { usePageDeleteModalActions } from '~/states/ui/modal/page-delete';
 import {
-  useSWRxUserBookmarks, useSWRMUTxCurrentUserBookmarks,
+  useSWRMUTxCurrentUserBookmarks,
+  useSWRxUserBookmarks,
 } from '~/stores/bookmark';
 import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
 import { mutateAllPageInfo, useSWRMUTxPageInfo } from '~/stores/page';
@@ -30,10 +30,10 @@ import styles from './BookmarkFolderTree.module.scss';
 //  } & IPageHasId
 
 type Props = {
-  isUserHomepage?: boolean,
-  userId?: string,
-  isOperable: boolean,
-}
+  isUserHomepage?: boolean;
+  userId?: string;
+  isOperable: boolean;
+};
 
 export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
   const { isUserHomepage, userId } = props;
@@ -44,10 +44,15 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
 
   const isReadOnlyUser = useIsReadOnlyUser();
   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 bookmarkFolderTreeMutation = useCallback(() => {
@@ -55,20 +60,43 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
     mutateCurrentUserBookmarks();
     mutatePageInfo();
     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. */
   // const itemDropHandler = async(item: DragItemDataType, dragType: string | null | symbol) => {
@@ -106,9 +134,12 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
 
   return (
     <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) => {
             return (
               <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
                 isReadOnlyUser={!!isReadOnlyUser}
                 isOperable={props.isOperable}
@@ -156,7 +190,6 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
           </DragAndDropWrapper>
         )} */}
       </div>
-
     </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 { DevidedPagePath } from '@growi/core/dist/models';
 import { pathUtils } from '@growi/core/dist/utils';
-import { useRouter } from 'next/router';
+import nodePath from 'path';
 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 { addBookmarkToFolder, renamePage } from '~/client/util/bookmark-utils';
 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 { useFetchCurrentPage } from '~/states/page';
 import { usePutBackPageModalActions } from '~/states/ui/modal/put-back-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 { BookmarkItemRenameInput } from './BookmarkItemRenameInput';
 import { BookmarkMoveToRootBtn } from './BookmarkMoveToRootBtn';
 import { DragAndDropWrapper } from './DragAndDropWrapper';
 
 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 => {
   const BASE_FOLDER_PADDING = 15;
@@ -48,46 +52,56 @@ export const BookmarkItem = (props: Props): JSX.Element => {
   const router = useRouter();
 
   const {
-    isReadOnlyUser, isOperable, bookmarkedPage, onClickDeleteMenuItemHandler,
-    parentFolder, level, canMoveToRoot, bookmarkFolderTreeMutation,
+    isReadOnlyUser,
+    isOperable,
+    bookmarkedPage,
+    onClickDeleteMenuItemHandler,
+    parentFolder,
+    level,
+    canMoveToRoot,
+    bookmarkFolderTreeMutation,
   } = props;
   const { open: openPutBackPageModal } = usePutBackPageModalActions();
   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 paddingLeft = BASE_BOOKMARK_PADDING + (BASE_FOLDER_PADDING * (level));
+  const paddingLeft = BASE_BOOKMARK_PADDING + BASE_FOLDER_PADDING * level;
   const dragItem: Partial<DragItemDataType> = {
-    ...bookmarkedPage, parentFolder,
+    ...bookmarkedPage,
+    parentFolder,
   };
 
   const bookmarkedPageId = bookmarkedPage?._id;
   const bookmarkedPagePath = bookmarkedPage?.path;
   const bookmarkedPageRevision = bookmarkedPage?.revision;
 
-  const onClickMoveToRootHandler = useCallback(async() => {
+  const onClickMoveToRootHandler = useCallback(async () => {
     if (bookmarkedPageId == null) return;
 
     try {
       await addBookmarkToFolder(bookmarkedPageId, null);
       bookmarkFolderTreeMutation();
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
     }
   }, [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(() => {
     setRenameInputShown(true);
@@ -97,57 +111,81 @@ export const BookmarkItem = (props: Props): JSX.Element => {
     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(() => {
     if (bookmarkedPage == null) return;
 
     const { _id: pageId, path } = bookmarkedPage;
-    const putBackedHandler = async() => {
+    const putBackedHandler = async () => {
       try {
         await unlink(path);
         mutateAllPageInfo();
@@ -155,36 +193,41 @@ export const BookmarkItem = (props: Props): JSX.Element => {
         router.push(`/${pageId}`);
         fetchCurrentPage({ force: true });
         toastSuccess(t('page_has_been_reverted', { path }));
-      }
-      catch (err) {
+      } catch (err) {
         toastError(err);
       }
     };
     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 {
-        pageTitle: '',
-        formerPagePath: '',
-        isFormerRoot: false,
+        pageTitle: dPagePath.latter,
+        formerPagePath: dPagePath.former,
+        isFormerRoot: dPagePath.isFormerRoot,
         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) {
     return <></>;
@@ -202,15 +245,21 @@ export const BookmarkItem = (props: Props): JSX.Element => {
         id={bookmarkItemId}
         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">
           <PageItemControl
@@ -224,11 +273,21 @@ export const BookmarkItem = (props: Props): JSX.Element => {
             onClickRenameMenuItem={renameMenuItemClickHandler}
             onClickDeleteMenuItem={deleteMenuItemClickHandler}
             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>
             </DropdownToggle>
           </PageItemControl>

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

@@ -1,19 +1,26 @@
 import type { ChangeEvent, JSX } from 'react';
 import { useCallback, useRef, useState } from 'react';
-
 import { useRect } from '@growi/ui/dist/utils';
 import { useTranslation } from 'next-i18next';
 import type { AutosizeInputProps } from 'react-input-autosize';
 import { debounce } from 'throttle-debounce';
 
 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';
 
-
-type Props = Pick<SubmittableInputProps<AutosizeInputProps>, 'value' | 'onSubmit' | 'onCancel'>;
+type Props = Pick<
+  SubmittableInputProps<AutosizeInputProps>,
+  'value' | 'onSubmit' | 'onCancel'
+>;
 
 export const BookmarkItemRenameInput = (props: Props): JSX.Element => {
   const { t } = useTranslation();
@@ -23,15 +30,18 @@ export const BookmarkItemRenameInput = (props: Props): JSX.Element => {
   const parentRef = useRef<HTMLDivElement>(null);
   const [parentRect] = useRect(parentRef);
 
-  const [validationResult, setValidationResult] = useState<InputValidationResult>();
-
+  const [validationResult, setValidationResult] =
+    useState<InputValidationResult>();
 
   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 cancelHandler = useCallback(() => {
@@ -41,9 +51,14 @@ export const BookmarkItemRenameInput = (props: Props): JSX.Element => {
 
   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 (
     <div className="flex-fill" ref={parentRef}>
@@ -52,17 +67,22 @@ export const BookmarkItemRenameInput = (props: Props): JSX.Element => {
         inputClassName={`form-control ${isInvalid ? 'is-invalid' : ''}`}
         inputStyle={{ maxWidth }}
         placeholder={t('Input page name')}
-        aria-describedby={isInvalid ? 'bookmark-item-rename-input-feedback' : undefined}
+        aria-describedby={
+          isInvalid ? 'bookmark-item-rename-input-feedback' : undefined
+        }
         autoFocus
         onChange={changeHandlerDebounced}
         onSubmit={onSubmit}
         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}
         </div>
-      ) }
+      )}
     </div>
   );
 };

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

@@ -1,11 +1,10 @@
 import React from 'react';
-
 import { useTranslation } from 'react-i18next';
 import { DropdownItem } from 'reactstrap';
 
 export const BookmarkMoveToRootBtn: React.FC<{
-  pageId: string
-  onClickMoveToRootHandler: (pageId: string) => Promise<void>
+  pageId: string;
+  onClickMoveToRootHandler: (pageId: string) => Promise<void>;
 }> = React.memo(({ pageId, onClickMoveToRootHandler }) => {
   const { t } = useTranslation();
 
@@ -14,7 +13,9 @@ export const BookmarkMoveToRootBtn: React.FC<{
       onClick={() => onClickMoveToRootHandler(pageId)}
       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')}
     </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 type { DragItemDataType } from '~/interfaces/bookmark-info';
 
 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 {
-    item, children, useDragMode, useDropMode, type, onDropItem, isDropable,
+    item,
+    children,
+    useDragMode,
+    useDropMode,
+    type,
+    onDropItem,
+    isDropable,
   } = props;
 
-
   const acceptedTypes = type;
   const sourcetype: string | symbol = type[0];
 
-
   const [, dragRef] = useDrag({
     type: sourcetype,
     item,
-    collect: monitor => ({
+    collect: (monitor) => ({
       isDragging: monitor.isDragging(),
       canDrag: monitor.canDrag(),
     }),
@@ -48,7 +59,7 @@ export const DragAndDropWrapper = (props: DragAndDropWrapperProps): JSX.Element
       }
       return false;
     },
-    collect: monitor => ({
+    collect: (monitor) => ({
       isOver: monitor.isOver({ shallow: true }) && monitor.canDrop(),
     }),
   }));
@@ -57,17 +68,18 @@ export const DragAndDropWrapper = (props: DragAndDropWrapperProps): JSX.Element
     if (useDragMode && useDropMode) {
       dragRef(c);
       dropRef(c);
-    }
-    else if (useDragMode) {
+    } else if (useDragMode) {
       dragRef(c);
-    }
-    else if (useDropMode) {
+    } else if (useDropMode) {
       dropRef(c);
     }
   };
 
   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}
     </div>
   );

+ 4 - 4
apps/app/src/client/components/DescendantsPageListModal/DescendantsPageListModal.spec.tsx

@@ -1,4 +1,4 @@
-import { render, screen, fireEvent } from '@testing-library/react';
+import { fireEvent, render, screen } from '@testing-library/react';
 
 import { DescendantsPageListModal } from './DescendantsPageListModal';
 
@@ -33,7 +33,9 @@ vi.mock('~/states/context', () => ({
 }));
 
 vi.mock('../DescendantsPageList', () => ({
-  DescendantsPageList: () => <div data-testid="descendants-page-list">DescendantsPageList</div>,
+  DescendantsPageList: () => (
+    <div data-testid="descendants-page-list">DescendantsPageList</div>
+  ),
 }));
 
 vi.mock('../PageTimeline', () => ({
@@ -41,7 +43,6 @@ vi.mock('../PageTimeline', () => ({
 }));
 
 describe('DescendantsPageListModal.tsx', () => {
-
   it('should render the modal when isOpened is true', () => {
     render(<DescendantsPageListModal />);
     expect(screen.getByTestId('descendants-page-list-modal')).not.toBeNull();
@@ -55,7 +56,6 @@ describe('DescendantsPageListModal.tsx', () => {
   });
 
   describe('when device is larger than lg', () => {
-
     it('should render CustomNavTab', () => {
       render(<DescendantsPageListModal />);
       expect(screen.getByTestId('custom-nav-tab')).not.toBeNull();

+ 72 - 36
apps/app/src/client/components/DescendantsPageListModal/DescendantsPageListModal.tsx

@@ -1,18 +1,16 @@
-
-import React, {
-  useState, useMemo, useEffect, useCallback,
-} from 'react';
-
-import { useTranslation } from 'next-i18next';
+import type React from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
 import dynamic from 'next/dynamic';
 import { useRouter } from 'next/router';
-import {
-  Modal, ModalHeader, ModalBody,
-} from 'reactstrap';
+import { useTranslation } from 'next-i18next';
+import { Modal, ModalBody, ModalHeader } from 'reactstrap';
 
 import { useIsSharedUser } from '~/states/context';
 import { useDeviceLargerThanLg } from '~/states/ui/device';
-import { useDescendantsPageListModalActions, useDescendantsPageListModalStatus } from '~/states/ui/modal/descendants-page-list';
+import {
+  useDescendantsPageListModalActions,
+  useDescendantsPageListModalStatus,
+} from '~/states/ui/modal/descendants-page-list';
 
 import { CustomNavDropdown, CustomNavTab } from '../CustomNavigation/CustomNav';
 import CustomTabContent from '../CustomNavigation/CustomTabContent';
@@ -21,9 +19,38 @@ import ExpandOrContractButton from '../ExpandOrContractButton';
 
 import styles from './DescendantsPageListModal.module.scss';
 
-const DescendantsPageList = dynamic<DescendantsPageListProps>(() => import('../DescendantsPageList').then(mod => mod.DescendantsPageList), { ssr: false });
+const DescendantsPageList = dynamic<DescendantsPageListProps>(
+  () => import('../DescendantsPageList').then((mod) => mod.DescendantsPageList),
+  { ssr: false },
+);
+
+const PageTimeline = dynamic(
+  () => import('../PageTimeline').then((mod) => mod.PageTimeline),
+  { ssr: false },
+);
+
+const PageListTabIcon = (): React.JSX.Element => (
+  <span className="material-symbols-outlined">subject</span>
+);
+
+const PageListTabContent = (): React.JSX.Element => {
+  const status = useDescendantsPageListModalStatus();
+  const path = status?.path;
+
+  if (path == null) {
+    return <></>;
+  }
+
+  return <DescendantsPageList path={path} />;
+};
+
+const TimelineTabIcon = (): React.JSX.Element => (
+  <span data-testid="timeline-tab-button" className="material-symbols-outlined">
+    timeline
+  </span>
+);
 
-const PageTimeline = dynamic(() => import('../PageTimeline').then(mod => mod.PageTimeline), { ssr: false });
+const TimelineTabContent = (): React.JSX.Element => <PageTimeline />;
 
 /**
  * DescendantsPageListModalSubstance - Presentation component (all logic here)
@@ -58,26 +85,19 @@ const DescendantsPageListModalSubstance = ({
   const navTabMapping = useMemo(() => {
     return {
       pagelist: {
-        Icon: () => <span className="material-symbols-outlined">subject</span>,
-        Content: () => {
-          if (path == null) {
-            return <></>;
-          }
-          return <DescendantsPageList path={path} />;
-        },
+        Icon: PageListTabIcon,
+        Content: PageListTabContent,
         i18n: t('page_list'),
         isLinkEnabled: () => !isSharedUser,
       },
       timeline: {
-        Icon: () => <span data-testid="timeline-tab-button" className="material-symbols-outlined">timeline</span>,
-        Content: () => {
-          return <PageTimeline />;
-        },
+        Icon: TimelineTabIcon,
+        Content: TimelineTabContent,
         i18n: t('Timeline View'),
         isLinkEnabled: () => !isSharedUser,
       },
     };
-  }, [isSharedUser, path, t]);
+  }, [isSharedUser, t]);
 
   // Memoize event handlers
   const expandWindow = useCallback(() => {
@@ -90,20 +110,32 @@ const DescendantsPageListModalSubstance = ({
   }, [onExpandedChange]);
   const onNavSelected = useCallback((v: string) => setActiveTab(v), []);
 
-  const buttons = useMemo(() => (
-    <span className="me-3">
-      <ExpandOrContractButton
-        isWindowExpanded={isWindowExpanded}
-        expandWindow={expandWindow}
-        contractWindow={contractWindow}
-      />
-      <button type="button" className="btn btn-close ms-2" onClick={closeModal} aria-label="Close"></button>
-    </span>
-  ), [closeModal, isWindowExpanded, expandWindow, contractWindow]);
+  const buttons = useMemo(
+    () => (
+      <span className="me-3">
+        <ExpandOrContractButton
+          isWindowExpanded={isWindowExpanded}
+          expandWindow={expandWindow}
+          contractWindow={contractWindow}
+        />
+        <button
+          type="button"
+          className="btn btn-close ms-2"
+          onClick={closeModal}
+          aria-label="Close"
+        ></button>
+      </span>
+    ),
+    [closeModal, isWindowExpanded, expandWindow, contractWindow],
+  );
 
   return (
     <div>
-      <ModalHeader className={isDeviceLargerThanLg ? 'p-0' : ''} toggle={closeModal} close={buttons}>
+      <ModalHeader
+        className={isDeviceLargerThanLg ? 'p-0' : ''}
+        toggle={closeModal}
+        close={buttons}
+      >
         {isDeviceLargerThanLg && (
           <CustomNavTab
             activeTab={activeTab}
@@ -125,7 +157,11 @@ const DescendantsPageListModalSubstance = ({
         <CustomTabContent
           activeTab={activeTab}
           navTabMapping={navTabMapping}
-          additionalClassNames={!isDeviceLargerThanLg ? ['grw-tab-content-style-md-down'] : undefined}
+          additionalClassNames={
+            !isDeviceLargerThanLg
+              ? ['grw-tab-content-style-md-down']
+              : undefined
+          }
         />
       </ModalBody>
     </div>

+ 4 - 1
apps/app/src/client/components/DescendantsPageListModal/dynamic.tsx

@@ -10,7 +10,10 @@ export const DescendantsPageListModalLazyLoaded = (): JSX.Element => {
 
   const DescendantsPageListModal = useLazyLoader<DescendantsPageListModalProps>(
     'descendants-page-list-modal',
-    () => import('./DescendantsPageListModal').then(mod => ({ default: mod.DescendantsPageListModal })),
+    () =>
+      import('./DescendantsPageListModal').then((mod) => ({
+        default: mod.DescendantsPageListModal,
+      })),
     status?.isOpened ?? false,
   );
 

+ 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 { useRipple } from 'react-use-ripple';
 import {
-  Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
+  Dropdown,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
 } from 'reactstrap';
 
 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';
 
@@ -20,11 +23,14 @@ export const InAppNotificationDropdown = (): JSX.Element => {
   const limit = 6;
 
   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
   const buttonRef = useRef(null);
@@ -43,9 +49,12 @@ export const InAppNotificationDropdown = (): JSX.Element => {
     }
   }, [mutateInAppNotificationUnreadStatusCount, socket]);
 
-
-  const toggleDropdownHandler = async() => {
-    if (!isOpen && inAppNotificationUnreadStatusCount != null && inAppNotificationUnreadStatusCount > 0) {
+  const toggleDropdownHandler = async () => {
+    if (
+      !isOpen &&
+      inAppNotificationUnreadStatusCount != null &&
+      inAppNotificationUnreadStatusCount > 0
+    ) {
       mutateInAppNotificationUnreadStatusCount();
     }
 
@@ -56,34 +65,45 @@ export const InAppNotificationDropdown = (): JSX.Element => {
     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 (
-    <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}>
         <span className="material-symbols-outlined">notifications</span> {badge}
       </DropdownToggle>
 
-      { isOpen && (
+      {isOpen && (
         <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 tag="a" href="/me/all-in-app-notifications">
-            { t('in_app_notification.see_all') }
+            {t('in_app_notification.see_all')}
           </DropdownItem>
         </DropdownMenu>
-      ) }
+      )}
     </Dropdown>
   );
 };

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

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

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

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

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

@@ -1,6 +1,5 @@
 import type { FC } from 'react';
 import React, { useState } from 'react';
-
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
@@ -8,117 +7,143 @@ import { useTranslation } from 'next-i18next';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
 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 PaginationWrapper from '../PaginationWrapper';
-
 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 showPageLimitationXL = useAtomValue(showPageLimitationXLAtom);
-
   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>
+        )}
+      {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>
-      );
-    }
+      )}
+    </>
+  );
+};
 
-    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 = {
     user_infomation: {
-      Icon: () => <></>,
-      Content: () => InAppNotificationCategoryByStatus(),
+      Icon: EmptyIcon,
+      Content: InAppNotificationAllTabContent,
       i18n: t('in_app_notification.all'),
     },
     external_accounts: {
-      Icon: () => <></>,
-      Content: () => InAppNotificationCategoryByStatus(InAppNotificationStatuses.STATUS_UNOPENED),
+      Icon: EmptyIcon,
+      Content: InAppNotificationUnreadTabContent,
       i18n: t('in_app_notification.unopend'),
     },
   };
 
   return (
     <div data-testid="grw-in-app-notification-page">
-      <CustomNavAndContents navTabMapping={navTabMapping} tabContentClasses={['mt-4']} />
+      <CustomNavAndContents
+        navTabMapping={navTabMapping}
+        tabContentClasses={['mt-4']}
+      />
     </div>
   );
 };

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

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

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

@@ -1,6 +1,5 @@
 import React from 'react';
-
-import { isPopulated, type HasObjectId } from '@growi/core';
+import { type HasObjectId, isPopulated } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 
 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 * as pageBulkExportJobSerializers from '~/models/serializers/in-app-notification-snapshot/page-bulk-export-job';
 
+import type { ModelNotificationUtils } from '.';
 import { ModelNotification } from './ModelNotification';
 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 { actionMsg, actionIcon } = useActionMsgAndIconForModelNotification(notification);
+  const { actionMsg, actionIcon } =
+    useActionMsgAndIconForModelNotification(notification);
 
   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)) {
@@ -31,14 +34,31 @@ export const usePageBulkExportJobModelNotification = (notification: IInAppNotifi
 
   const actionUsers = notification.user.username;
 
-  notification.parsedSnapshot = pageBulkExportJobSerializers.parseSnapshot(notification.snapshot);
+  notification.parsedSnapshot = pageBulkExportJobSerializers.parseSnapshot(
+    notification.snapshot,
+  );
 
   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 <></>;
   };
@@ -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 {
     Notification,
     clickLink,
     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 type { IPage, HasObjectId } from '@growi/core';
 import { useRouter } from 'next/router';
+import type { HasObjectId, IPage } from '@growi/core';
 
 import { SupportedTargetModel } from '~/interfaces/activity';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
 import * as pageSerializers from '~/models/serializers/in-app-notification-snapshot/page';
 
+import type { ModelNotificationUtils } from '.';
 import { ModelNotification } from './ModelNotification';
 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 { actionMsg, actionIcon } = useActionMsgAndIconForModelNotification(notification);
+  const { actionMsg, actionIcon } =
+    useActionMsgAndIconForModelNotification(notification);
 
   const getActionUsers = useCallback(() => {
     const latestActionUsers = notification.actionUsers.slice(0, 3);
@@ -27,18 +27,18 @@ export const usePageModelNotification = (notification: IInAppNotification & HasO
     const latestUsersCount = latestUsers.length;
     if (latestUsersCount === 1) {
       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`;
-    }
-    else {
+    } else {
       actionedUsers = latestUsers.join(', ');
     }
 
     return actionedUsers;
   }, [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;
   };
 
@@ -48,7 +48,9 @@ export const usePageModelNotification = (notification: IInAppNotification & HasO
 
   const actionUsers = getActionUsers();
 
-  notification.parsedSnapshot = pageSerializers.parseSnapshot(notification.snapshot);
+  notification.parsedSnapshot = pageSerializers.parseSnapshot(
+    notification.snapshot,
+  );
 
   const Notification = () => {
     return (
@@ -75,5 +77,4 @@ export const usePageModelNotification = (notification: IInAppNotification & HasO
     Notification,
     publishOpen,
   };
-
 };

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

@@ -1,23 +1,24 @@
 import React from 'react';
-
-import type { IUser, HasObjectId } from '@growi/core';
 import { useRouter } from 'next/router';
+import type { HasObjectId, IUser } from '@growi/core';
 
 import { SupportedTargetModel } from '~/interfaces/activity';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
 
+import type { ModelNotificationUtils } from '.';
 import { ModelNotification } from './ModelNotification';
 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 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;
   };
 
@@ -46,5 +47,4 @@ export const useUserModelNotification = (notification: IInAppNotification & HasO
     Notification,
     publishOpen,
   };
-
 };

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

@@ -1,31 +1,33 @@
 import type { FC } from 'react';
-
 import type { HasObjectId } from '@growi/core';
 
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
 
-
 import { usePageBulkExportJobModelNotification } from './PageBulkExportJobModelNotification';
 import { usePageModelNotification } from './PageModelNotification';
 import { useUserModelNotification } from './UserModelNotification';
 
 export interface ModelNotificationUtils {
-  Notification: FC
-  publishOpen?: () => void
-  clickLink?: string
+  Notification: FC;
+  publishOpen?: () => void;
+  clickLink?: string;
   // Whether actions from clicking notification is disabled or not.
   // 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 userModelNotificationUtils = useUserModelNotification(notification);
-  const pageBulkExportResultModelNotificationUtils = usePageBulkExportJobModelNotification(notification);
-
-  const modelNotificationUtils = pageModelNotificationUtils ?? userModelNotificationUtils ?? pageBulkExportResultModelNotificationUtils;
+  const pageBulkExportResultModelNotificationUtils =
+    usePageBulkExportJobModelNotification(notification);
 
+  const modelNotificationUtils =
+    pageModelNotificationUtils ??
+    userModelNotificationUtils ??
+    pageBulkExportResultModelNotificationUtils;
 
   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';
 
 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;
   let actionMsg: string;
   let actionIcon: string;

+ 0 - 2
apps/app/src/client/components/ItemsTree/ItemsTreeContentSkeleton.tsx

@@ -4,9 +4,7 @@ import { Skeleton } from '~/client/components/Skeleton';
 
 import styles from './ItemsTreeContentSkeleton.module.scss';
 
-
 const ItemsTreeContentSkeleton = (): JSX.Element => {
-
   return (
     <ul className="list-group py-3">
       <Skeleton additionalClass={`${styles['text-skeleton-level1']} pe-3`} />

+ 21 - 9
apps/app/src/client/components/LoginForm/ExternalAuthButton.tsx

@@ -1,14 +1,21 @@
-import { useCallback, type JSX } from 'react';
-
+import { type JSX, useCallback } from 'react';
 import { useTranslation } from 'next-i18next';
 
 import { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
 
 const authIcon = {
-  [IExternalAuthProviderType.google]: <span className="growi-custom-icons align-bottom">google</span>,
-  [IExternalAuthProviderType.github]: <span className="growi-custom-icons align-bottom">github</span>,
-  [IExternalAuthProviderType.oidc]: <span className="growi-custom-icons align-bottom">openid</span>,
-  [IExternalAuthProviderType.saml]: <span className="material-symbols-outlined align-bottom">key</span>,
+  [IExternalAuthProviderType.google]: (
+    <span className="growi-custom-icons align-bottom">google</span>
+  ),
+  [IExternalAuthProviderType.github]: (
+    <span className="growi-custom-icons align-bottom">github</span>
+  ),
+  [IExternalAuthProviderType.oidc]: (
+    <span className="growi-custom-icons align-bottom">openid</span>
+  ),
+  [IExternalAuthProviderType.saml]: (
+    <span className="material-symbols-outlined align-bottom">key</span>
+  ),
 };
 
 const authLabel = {
@@ -18,8 +25,11 @@ const authLabel = {
   [IExternalAuthProviderType.saml]: 'SAML',
 };
 
-
-export const ExternalAuthButton = ({ authType }: {authType: IExternalAuthProviderType}): JSX.Element => {
+export const ExternalAuthButton = ({
+  authType,
+}: {
+  authType: IExternalAuthProviderType;
+}): JSX.Element => {
   const { t } = useTranslation();
 
   const key = `btn-auth-${authType.toString()}`;
@@ -37,7 +47,9 @@ export const ExternalAuthButton = ({ authType }: {authType: IExternalAuthProvide
       onClick={handleLoginWithExternalAuth}
     >
       <span>{authIcon[authType]}</span>
-      <span className="flex-grow-1">{t('Sign in with External auth', { signin: authLabel[authType] })}</span>
+      <span className="flex-grow-1">
+        {t('Sign in with External auth', { signin: authLabel[authType] })}
+      </span>
     </button>
   );
 };

+ 34 - 20
apps/app/src/client/components/LoginForm/LoginForm.spec.tsx

@@ -1,11 +1,6 @@
 import React from 'react';
-
-import {
-  render, screen, fireEvent, waitFor,
-} from '@testing-library/react';
-import {
-  describe, it, expect, vi, beforeEach,
-} from 'vitest';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 import type { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
@@ -89,7 +84,9 @@ describe('LoginForm - Error Display', () => {
 
       render(<LoginForm {...props} />);
 
-      expect(screen.getByText('jwks must be a JSON Web Key Set formatted object')).toBeInTheDocument();
+      expect(
+        screen.getByText('jwks must be a JSON Web Key Set formatted object'),
+      ).toBeInTheDocument();
     });
   });
 
@@ -110,7 +107,9 @@ describe('LoginForm - Error Display', () => {
 
       render(<LoginForm {...props} />);
 
-      expect(screen.getByText('jwks must be a JSON Web Key Set formatted object')).toBeInTheDocument();
+      expect(
+        screen.getByText('jwks must be a JSON Web Key Set formatted object'),
+      ).toBeInTheDocument();
     });
 
     it('should not render local/LDAP form but should still show errors', () => {
@@ -129,9 +128,15 @@ describe('LoginForm - Error Display', () => {
 
       render(<LoginForm {...props} />);
 
-      expect(screen.queryByTestId('tiUsernameForLogin')).not.toBeInTheDocument();
-      expect(screen.queryByTestId('tiPasswordForLogin')).not.toBeInTheDocument();
-      expect(screen.getByText('OIDC authentication failed')).toBeInTheDocument();
+      expect(
+        screen.queryByTestId('tiUsernameForLogin'),
+      ).not.toBeInTheDocument();
+      expect(
+        screen.queryByTestId('tiPasswordForLogin'),
+      ).not.toBeInTheDocument();
+      expect(
+        screen.getByText('OIDC authentication failed'),
+      ).toBeInTheDocument();
     });
   });
 
@@ -153,7 +158,7 @@ describe('LoginForm - Error Display', () => {
       expect(screen.getByText('External error message')).toBeInTheDocument();
     });
 
-    it('should prioritize login errors over external account login errors after failed login', async() => {
+    it('should prioritize login errors over external account login errors after failed login', async () => {
       const externalAccountLoginError = {
         message: 'External error message',
         name: 'ExternalAccountLoginError',
@@ -190,18 +195,23 @@ describe('LoginForm - Error Display', () => {
 
       // Wait for login error to appear and external error to be replaced
       await waitFor(() => {
-        expect(screen.getByText('Invalid username or password')).toBeInTheDocument();
+        expect(
+          screen.getByText('Invalid username or password'),
+        ).toBeInTheDocument();
       });
 
       // External error should no longer be visible when login error exists
-      expect(screen.queryByText('External error message')).not.toBeInTheDocument();
+      expect(
+        screen.queryByText('External error message'),
+      ).not.toBeInTheDocument();
     });
 
-    it('should display dangerouslySetInnerHTML errors for PROVIDER_DUPLICATED_USERNAME_EXCEPTION', async() => {
+    it('should display dangerouslySetInnerHTML errors for PROVIDER_DUPLICATED_USERNAME_EXCEPTION', async () => {
       // Mock API call to return PROVIDER_DUPLICATED_USERNAME_EXCEPTION error
       mockApiv3Post.mockRejectedValueOnce([
         {
-          message: 'This username is already taken by <a href="/login">another provider</a>',
+          message:
+            'This username is already taken by <a href="/login">another provider</a>',
           code: 'provider-duplicated-username-exception',
           args: {},
         },
@@ -226,11 +236,13 @@ describe('LoginForm - Error Display', () => {
       // Wait for the dangerouslySetInnerHTML error to appear
       await waitFor(() => {
         // Check that the error with HTML content is rendered
-        expect(screen.getByText(/This username is already taken by/)).toBeInTheDocument();
+        expect(
+          screen.getByText(/This username is already taken by/),
+        ).toBeInTheDocument();
       });
     });
 
-    it('should handle multiple login errors correctly', async() => {
+    it('should handle multiple login errors correctly', async () => {
       // Mock API call to return multiple errors
       mockApiv3Post.mockRejectedValueOnce([
         {
@@ -281,7 +293,9 @@ describe('LoginForm - Error Display', () => {
 
       render(<LoginForm {...props} />);
 
-      expect(screen.getByText('Authentication service unavailable')).toBeInTheDocument();
+      expect(
+        screen.getByText('Authentication service unavailable'),
+      ).toBeInTheDocument();
     });
   });
 });

+ 299 - 183
apps/app/src/client/components/LoginForm/LoginForm.tsx

@@ -1,10 +1,7 @@
-import React, {
-  useState, useEffect, useCallback, type JSX,
-} from 'react';
-
+import React, { type JSX, useCallback, useEffect, useState } from 'react';
+import { useRouter } from 'next/router';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
-import { useRouter } from 'next/router';
 import ReactCardFlip from 'react-card-flip';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
@@ -17,43 +14,50 @@ import { RegistrationMode } from '~/interfaces/registration-mode';
 import { toArrayIfNot } from '~/utils/array-utils';
 
 import { CompleteUserRegistration } from '../CompleteUserRegistration';
-
 import { ExternalAuthButton } from './ExternalAuthButton';
 
 import styles from './LoginForm.module.scss';
 
-
 const moduleClass = styles['login-form'];
 
-
 type LoginFormProps = {
-  username?: string,
-  name?: string,
-  email?: string,
-  isEmailAuthenticationEnabled: boolean,
-  registrationMode: RegistrationMode,
-  registrationWhitelist: string[],
-  isPasswordResetEnabled: boolean,
-  isLocalStrategySetup: boolean,
-  isLdapStrategySetup: boolean,
-  isLdapSetupFailed: boolean,
-  enabledExternalAuthType?: IExternalAuthProviderType[],
-  isMailerSetup?: boolean,
-  externalAccountLoginError?: IExternalAccountLoginError,
-  minPasswordLength: number,
-}
+  username?: string;
+  name?: string;
+  email?: string;
+  isEmailAuthenticationEnabled: boolean;
+  registrationMode: RegistrationMode;
+  registrationWhitelist: string[];
+  isPasswordResetEnabled: boolean;
+  isLocalStrategySetup: boolean;
+  isLdapStrategySetup: boolean;
+  isLdapSetupFailed: boolean;
+  enabledExternalAuthType?: IExternalAuthProviderType[];
+  isMailerSetup?: boolean;
+  externalAccountLoginError?: IExternalAccountLoginError;
+  minPasswordLength: number;
+};
 export const LoginForm = (props: LoginFormProps): JSX.Element => {
   const { t } = useTranslation();
 
   const router = useRouter();
 
   const {
-    isLocalStrategySetup, isLdapStrategySetup, isLdapSetupFailed, isPasswordResetEnabled,
-    isEmailAuthenticationEnabled, registrationMode, registrationWhitelist, isMailerSetup, enabledExternalAuthType, minPasswordLength,
+    isLocalStrategySetup,
+    isLdapStrategySetup,
+    isLdapSetupFailed,
+    isPasswordResetEnabled,
+    isEmailAuthenticationEnabled,
+    registrationMode,
+    registrationWhitelist,
+    isMailerSetup,
+    enabledExternalAuthType,
+    minPasswordLength,
   } = props;
 
-  const isLocalOrLdapStrategiesEnabled = isLocalStrategySetup || isLdapStrategySetup;
-  const isSomeExternalAuthEnabled = enabledExternalAuthType != null && enabledExternalAuthType.length > 0;
+  const isLocalOrLdapStrategiesEnabled =
+    isLocalStrategySetup || isLdapStrategySetup;
+  const isSomeExternalAuthEnabled =
+    enabledExternalAuthType != null && enabledExternalAuthType.length > 0;
 
   // states
   const [isRegistering, setIsRegistering] = useState(false);
@@ -69,11 +73,13 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
   const [passwordForRegister, setPasswordForRegister] = useState('');
   const [registerErrors, setRegisterErrors] = useState<IErrorV3[]>([]);
   // For UserActivation
-  const [emailForRegistrationOrder, setEmailForRegistrationOrder] = useState('');
+  const [emailForRegistrationOrder, setEmailForRegistrationOrder] =
+    useState('');
 
   const [isSuccessToRagistration, setIsSuccessToRagistration] = useState(false);
 
-  const isRegistrationEnabled = isLocalStrategySetup && registrationMode !== RegistrationMode.CLOSED;
+  const isRegistrationEnabled =
+    isLocalStrategySetup && registrationMode !== RegistrationMode.CLOSED;
 
   const tWithOpt = useTWithOpt();
 
@@ -89,34 +95,35 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     setLoginErrors([]);
   }, [loginErrors.length]);
 
-  const handleLoginWithLocalSubmit = useCallback(async(e) => {
-    e.preventDefault();
-    resetLoginErrors();
-    setIsLoading(true);
-
-    const loginForm = {
-      username: usernameForLogin,
-      password: passwordForLogin,
-    };
+  const handleLoginWithLocalSubmit = useCallback(
+    async (e) => {
+      e.preventDefault();
+      resetLoginErrors();
+      setIsLoading(true);
 
-    try {
-      const res = await apiv3Post('/login', { loginForm });
-      const { redirectTo } = res.data;
+      const loginForm = {
+        username: usernameForLogin,
+        password: passwordForLogin,
+      };
 
-      if (redirectTo != null) {
-        return router.push(redirectTo);
-      }
+      try {
+        const res = await apiv3Post('/login', { loginForm });
+        const { redirectTo } = res.data;
 
-      return router.push('/');
-    }
-    catch (err) {
-      const errs = toArrayIfNot(err);
-      setLoginErrors(errs);
-      setIsLoading(false);
-    }
-    return;
+        if (redirectTo != null) {
+          return router.push(redirectTo);
+        }
 
-  }, [passwordForLogin, resetLoginErrors, router, usernameForLogin]);
+        return router.push('/');
+      } catch (err) {
+        const errs = toArrayIfNot(err);
+        setLoginErrors(errs);
+        setIsLoading(false);
+      }
+      return;
+    },
+    [passwordForLogin, resetLoginErrors, router, usernameForLogin],
+  );
 
   // separate errors based on error code
   const separateErrorsBasedOnErrorCode = useCallback((errors: IErrorV3[]) => {
@@ -126,8 +133,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     errors.forEach((err) => {
       if (err.code === LoginErrorCode.PROVIDER_DUPLICATED_USERNAME_EXCEPTION) {
         loginErrorListForDangerouslySetInnerHTML.push(err);
-      }
-      else {
+      } else {
         loginErrorList.push(err);
       }
     });
@@ -136,31 +142,48 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
   }, []);
 
   // wrap error elements which use dangerouslySetInnerHtml
-  const generateDangerouslySetErrors = useCallback((errors: IErrorV3[]): JSX.Element => {
-    if (errors == null || errors.length === 0) return <></>;
-    return (
-      <div className="alert alert-danger">
-        {errors.map((err) => {
-          // eslint-disable-next-line react/no-danger
-          return <small dangerouslySetInnerHTML={{ __html: tWithOpt(err.message, err.args) }}></small>;
-        })}
-      </div>
-    );
-  }, [tWithOpt]);
+  const generateDangerouslySetErrors = useCallback(
+    (errors: IErrorV3[]): JSX.Element => {
+      if (errors == null || errors.length === 0) return <></>;
+      return (
+        <div className="alert alert-danger">
+          {errors.map((err, index) => {
+            // eslint-disable-next-line react/no-danger
+            return (
+              <small
+                key={`${err.code}-${index}`}
+                // biome-ignore lint/security/noDangerouslySetInnerHtml: rendered HTML from translations
+                dangerouslySetInnerHTML={{
+                  __html: tWithOpt(err.message, err.args),
+                }}
+              ></small>
+            );
+          })}
+        </div>
+      );
+    },
+    [tWithOpt],
+  );
 
   // wrap error elements which do not use dangerouslySetInnerHtml
-  const generateSafelySetErrors = useCallback((errors: (IErrorV3 | IExternalAccountLoginError)[]): JSX.Element => {
-    if (errors == null || errors.length === 0) return <></>;
-    return (
-      <ul className="alert alert-danger">
-        {errors.map((err, index) => (
-          <small className={index > 0 ? 'mt-1' : ''}>
-            {tWithOpt(err.message, err.args)}
-          </small>
-        ))}
-      </ul>
-    );
-  }, [tWithOpt]);
+  const generateSafelySetErrors = useCallback(
+    (errors: (IErrorV3 | IExternalAccountLoginError)[]): JSX.Element => {
+      if (errors == null || errors.length === 0) return <></>;
+      return (
+        <ul className="alert alert-danger">
+          {errors.map((err, index) => (
+            <small
+              key={`${err.message}-${index}`}
+              className={index > 0 ? 'mt-1' : ''}
+            >
+              {tWithOpt(err.message, err.args)}
+            </small>
+          ))}
+        </ul>
+      );
+    },
+    [tWithOpt],
+  );
 
   const renderLocalOrLdapLoginForm = useCallback(() => {
     const { isLdapStrategySetup } = props;
@@ -175,16 +198,30 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
         {/* !! - END OF HIDDEN ELEMENT - !! */}
         {isLdapSetupFailed && (
           <div className="alert alert-warning small">
-            <strong><span className="material-symbols-outlined">info</span>{t('login.enabled_ldap_has_configuration_problem')}</strong><br />
+            <strong>
+              <span className="material-symbols-outlined">info</span>
+              {t('login.enabled_ldap_has_configuration_problem')}
+            </strong>
+            <br />
             {/* eslint-disable-next-line react/no-danger */}
-            <span dangerouslySetInnerHTML={{ __html: t('login.set_env_var_for_logs') }}></span>
+            <span
+              // biome-ignore lint/security/noDangerouslySetInnerHtml: rendered HTML from translations
+              dangerouslySetInnerHTML={{
+                __html: t('login.set_env_var_for_logs'),
+              }}
+            ></span>
           </div>
         )}
 
-        <form role="form" onSubmit={handleLoginWithLocalSubmit} id="login-form">
+        <form onSubmit={handleLoginWithLocalSubmit} id="login-form">
           <div className="input-group">
-            <label className="text-white opacity-75 d-flex align-items-center" htmlFor="tiUsernameForLogin">
-              <span className="material-symbols-outlined" aria-label="Username or E-mail">person</span>
+            <label
+              className="text-white opacity-75 d-flex align-items-center"
+              htmlFor="tiUsernameForLogin"
+            >
+              <span className="material-symbols-outlined" aria-hidden="true">
+                person
+              </span>
             </label>
             <input
               id="tiUsernameForLogin"
@@ -192,7 +229,9 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
               className={`form-control rounded ms-2 ${isLdapStrategySetup ? 'ldap-space' : ''}`}
               data-testid="tiUsernameForLogin"
               placeholder="Username or E-mail"
-              onChange={(e) => { setUsernameForLogin(e.target.value) }}
+              onChange={(e) => {
+                setUsernameForLogin(e.target.value);
+              }}
               name="usernameForLogin"
             />
             {isLdapStrategySetup && (
@@ -201,12 +240,16 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
                 <span className="">LDAP</span>
               </small>
             )}
-
           </div>
 
           <div className="input-group">
-            <label className="text-white opacity-75 d-flex align-items-center" htmlFor="tiPasswordForLogin">
-              <span className="material-symbols-outlined" aria-label="Password">lock</span>
+            <label
+              className="text-white opacity-75 d-flex align-items-center"
+              htmlFor="tiPasswordForLogin"
+            >
+              <span className="material-symbols-outlined" aria-hidden="true">
+                lock
+              </span>
             </label>
             <input
               id="tiPasswordForLogin"
@@ -214,7 +257,9 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
               className="form-control rounded ms-2"
               data-testid="tiPasswordForLogin"
               placeholder="Password"
-              onChange={(e) => { setPasswordForLogin(e.target.value) }}
+              onChange={(e) => {
+                setPasswordForLogin(e.target.value);
+              }}
               name="passwordForLogin"
             />
           </div>
@@ -230,7 +275,12 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
                 {isLoading ? (
                   <LoadingSpinner />
                 ) : (
-                  <span className="material-symbols-outlined" aria-label="Login">login</span>
+                  <span
+                    className="material-symbols-outlined"
+                    aria-hidden="true"
+                  >
+                    login
+                  </span>
                 )}
               </span>
               <span className="flex-grow-1">{t('Sign in')}</span>
@@ -239,10 +289,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
         </form>
       </>
     );
-  }, [
-    props, isLdapSetupFailed, t, handleLoginWithLocalSubmit, isLoading,
-  ]);
-
+  }, [props, isLdapSetupFailed, t, handleLoginWithLocalSubmit, isLoading]);
 
   const renderExternalAuthLoginForm = useCallback(() => {
     const { enabledExternalAuthType } = props;
@@ -254,7 +301,9 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     return (
       <>
         <div className="mt-2">
-          {enabledExternalAuthType.map(authType => <ExternalAuthButton authType={authType} />)}
+          {enabledExternalAuthType.map((authType) => (
+            <ExternalAuthButton key={authType} authType={authType} />
+          ))}
         </div>
       </>
     );
@@ -265,45 +314,55 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     setRegisterErrors([]);
   }, [registerErrors.length]);
 
-  const handleRegisterFormSubmit = useCallback(async(e, requestPath) => {
-    e.preventDefault();
-    setEmailForRegistrationOrder('');
-    setIsSuccessToRagistration(false);
-    setIsLoading(true);
-
-    const registerForm = {
-      username: usernameForRegister,
-      name: nameForRegister,
-      email: emailForRegister,
-      password: passwordForRegister,
-    };
-    try {
-      const res = await apiv3Post(requestPath, { registerForm });
-
-      setIsSuccessToRagistration(true);
-      resetRegisterErrors();
-      setIsLoading(false);
-
-      const { redirectTo } = res.data;
-
-      if (redirectTo != null) {
-        router.push(redirectTo);
-      }
+  const handleRegisterFormSubmit = useCallback(
+    async (e, requestPath) => {
+      e.preventDefault();
+      setEmailForRegistrationOrder('');
+      setIsSuccessToRagistration(false);
+      setIsLoading(true);
+
+      const registerForm = {
+        username: usernameForRegister,
+        name: nameForRegister,
+        email: emailForRegister,
+        password: passwordForRegister,
+      };
+      try {
+        const res = await apiv3Post(requestPath, { registerForm });
+
+        setIsSuccessToRagistration(true);
+        resetRegisterErrors();
+        setIsLoading(false);
+
+        const { redirectTo } = res.data;
+
+        if (redirectTo != null) {
+          router.push(redirectTo);
+        }
 
-      if (isEmailAuthenticationEnabled) {
-        setEmailForRegistrationOrder(emailForRegister);
-        return;
-      }
-    }
-    catch (err) {
-      // Execute if error exists
-      if (err != null || err.length > 0) {
-        setRegisterErrors(err);
+        if (isEmailAuthenticationEnabled) {
+          setEmailForRegistrationOrder(emailForRegister);
+          return;
+        }
+      } catch (err) {
+        // Execute if error exists
+        if (err != null || err.length > 0) {
+          setRegisterErrors(err);
+        }
+        setIsLoading(false);
       }
-      setIsLoading(false);
-    }
-    return;
-  }, [usernameForRegister, nameForRegister, emailForRegister, passwordForRegister, resetRegisterErrors, router, isEmailAuthenticationEnabled]);
+      return;
+    },
+    [
+      usernameForRegister,
+      nameForRegister,
+      emailForRegister,
+      passwordForRegister,
+      resetRegisterErrors,
+      router,
+      isEmailAuthenticationEnabled,
+    ],
+  );
 
   const switchForm = useCallback(() => {
     setIsRegistering(!isRegistering);
@@ -329,34 +388,37 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
             {t('page_register.notice.restricted_defail')}
           </p>
         )}
-        {(!isMailerSetup && isEmailAuthenticationEnabled) && (
+        {!isMailerSetup && isEmailAuthenticationEnabled && (
           <p className="alert alert-danger">
             <span>{t('commons:alert.please_enable_mailer')}</span>
           </p>
         )}
 
-        {
-          registerErrors != null && registerErrors.length > 0 && (
-            <p className="alert alert-danger">
-              {registerErrors.map(err => (
-                <span>
-                  {tWithOpt(err.message, err.args)}<br />
-                </span>
-              ))}
-            </p>
-          )
-        }
-
-        {
-          (isEmailAuthenticationEnabled && isSuccessToRagistration) && (
-            <p className="alert alert-success">
-              <span>{t('message.successfully_send_email_auth', { email: emailForRegistrationOrder })}</span>
-            </p>
-          )
-        }
+        {registerErrors != null && registerErrors.length > 0 && (
+          <p className="alert alert-danger">
+            {registerErrors.map((err, index) => (
+              <span key={`${err.message}-${index}`}>
+                {tWithOpt(err.message, err.args)}
+                <br />
+              </span>
+            ))}
+          </p>
+        )}
 
-        <form role="form" onSubmit={e => handleRegisterFormSubmit(e, registerAction)} id="register-form">
+        {isEmailAuthenticationEnabled && isSuccessToRagistration && (
+          <p className="alert alert-success">
+            <span>
+              {t('message.successfully_send_email_auth', {
+                email: emailForRegistrationOrder,
+              })}
+            </span>
+          </p>
+        )}
 
+        <form
+          onSubmit={(e) => handleRegisterFormSubmit(e, registerAction)}
+          id="register-form"
+        >
           {!isEmailAuthenticationEnabled && (
             <div>
               <div className="input-group" id="input-group-username">
@@ -367,7 +429,9 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
                 <input
                   type="text"
                   className="form-control rounded ms-2"
-                  onChange={(e) => { setUsernameForRegister(e.target.value) }}
+                  onChange={(e) => {
+                    setUsernameForRegister(e.target.value);
+                  }}
                   placeholder={t('User ID')}
                   name="username"
                   defaultValue={props.username}
@@ -385,7 +449,9 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
                 <input
                   type="text"
                   className="form-control rounded ms-2"
-                  onChange={(e) => { setNameForRegister(e.target.value) }}
+                  onChange={(e) => {
+                    setNameForRegister(e.target.value);
+                  }}
                   placeholder={t('Name')}
                   name="name"
                   defaultValue={props.name}
@@ -404,7 +470,9 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
               type="email"
               disabled={!isMailerSetup && isEmailAuthenticationEnabled}
               className="form-control rounded ms-2"
-              onChange={(e) => { setEmailForRegister(e.target.value) }}
+              onChange={(e) => {
+                setEmailForRegister(e.target.value);
+              }}
               placeholder={t('Email')}
               name="email"
               defaultValue={props.email}
@@ -437,7 +505,9 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
                 <input
                   type="password"
                   className="form-control rounded ms-2"
-                  onChange={(e) => { setPasswordForRegister(e.target.value) }}
+                  onChange={(e) => {
+                    setPasswordForRegister(e.target.value);
+                  }}
                   placeholder={t('Password')}
                   name="password"
                   required
@@ -452,7 +522,9 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
             <button
               type="submit"
               className="btn btn-secondary btn-register d-flex col-7"
-              disabled={(!isMailerSetup && isEmailAuthenticationEnabled) || isLoading}
+              disabled={
+                (!isMailerSetup && isEmailAuthenticationEnabled) || isLoading
+              }
             >
               <span>
                 {isLoading ? (
@@ -468,45 +540,82 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
 
         <div className="row">
           <div className="text-end col-12 mb-5">
-            <a
-              href="#login"
+            <button
+              type="button"
               className="btn btn-sm btn-secondary btn-function col-10 col-sm-9 mx-auto py-1 d-flex"
               style={{ pointerEvents: isLoading ? 'none' : undefined }}
               onClick={switchForm}
             >
               <span className="material-symbols-outlined fs-5">login</span>
               <span className="flex-grow-1">{t('Sign in is here')}</span>
-            </a>
+            </button>
           </div>
         </div>
       </React.Fragment>
     );
   }, [
-    t, isEmailAuthenticationEnabled, registrationMode, isMailerSetup, registerErrors, isSuccessToRagistration, emailForRegistrationOrder,
-    props.username, props.name, props.email, registrationWhitelist, minPasswordLength, isLoading, switchForm, tWithOpt, handleRegisterFormSubmit,
+    t,
+    isEmailAuthenticationEnabled,
+    registrationMode,
+    isMailerSetup,
+    registerErrors,
+    isSuccessToRagistration,
+    emailForRegistrationOrder,
+    props.username,
+    props.name,
+    props.email,
+    registrationWhitelist,
+    minPasswordLength,
+    isLoading,
+    switchForm,
+    tWithOpt,
+    handleRegisterFormSubmit,
   ]);
 
-  if (registrationMode === RegistrationMode.RESTRICTED && isSuccessToRagistration && !isEmailAuthenticationEnabled) {
+  if (
+    registrationMode === RegistrationMode.RESTRICTED &&
+    isSuccessToRagistration &&
+    !isEmailAuthenticationEnabled
+  ) {
     return <CompleteUserRegistration />;
   }
 
   return (
     <div className={moduleClass}>
-      <div className="nologin-dialog mx-auto rounded-4 rounded-top-0" id="nologin-dialog" data-testid="login-form">
+      <div
+        className="nologin-dialog mx-auto rounded-4 rounded-top-0"
+        id="nologin-dialog"
+        data-testid="login-form"
+      >
         <div className="row mx-0">
           <div className="col-12 px-md-4 pb-5">
-            <ReactCardFlip isFlipped={isRegistering} flipDirection="horizontal" cardZIndex="3">
+            <ReactCardFlip
+              isFlipped={isRegistering}
+              flipDirection="horizontal"
+              cardZIndex="3"
+            >
               <div className="front">
                 {/* Error display section - always shown regardless of login method configuration */}
                 {(() => {
                   // separate login errors into two arrays based on error code
-                  const [loginErrorListForDangerouslySetInnerHTML, loginErrorList] = separateErrorsBasedOnErrorCode(loginErrors);
+                  const [
+                    loginErrorListForDangerouslySetInnerHTML,
+                    loginErrorList,
+                  ] = separateErrorsBasedOnErrorCode(loginErrors);
                   // Generate login error elements using dangerouslySetInnerHTML
-                  const loginErrorElementWithDangerouslySetInnerHTML = generateDangerouslySetErrors(loginErrorListForDangerouslySetInnerHTML);
+                  const loginErrorElementWithDangerouslySetInnerHTML =
+                    generateDangerouslySetErrors(
+                      loginErrorListForDangerouslySetInnerHTML,
+                    );
                   // Generate login error elements - prioritize loginErrorList, fallback to externalAccountLoginError
-                  const loginErrorElement = (loginErrorList ?? []).length > 0
-                    ? generateSafelySetErrors(loginErrorList)
-                    : generateSafelySetErrors(props.externalAccountLoginError != null ? [props.externalAccountLoginError] : []);
+                  const loginErrorElement =
+                    (loginErrorList ?? []).length > 0
+                      ? generateSafelySetErrors(loginErrorList)
+                      : generateSafelySetErrors(
+                          props.externalAccountLoginError != null
+                            ? [props.externalAccountLoginError]
+                            : [],
+                        );
 
                   return (
                     <>
@@ -517,11 +626,12 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
                 })()}
 
                 {isLocalOrLdapStrategiesEnabled && renderLocalOrLdapLoginForm()}
-                {isLocalOrLdapStrategiesEnabled && isSomeExternalAuthEnabled && (
-                  <div className="text-center text-line d-flex align-items-center mb-3">
-                    <p className="text-white mb-0">{t('or')}</p>
-                  </div>
-                )}
+                {isLocalOrLdapStrategiesEnabled &&
+                  isSomeExternalAuthEnabled && (
+                    <div className="text-center text-line d-flex align-items-center mb-3">
+                      <p className="text-white mb-0">{t('or')}</p>
+                    </div>
+                  )}
                 {isSomeExternalAuthEnabled && renderExternalAuthLoginForm()}
                 {isLocalOrLdapStrategiesEnabled && isPasswordResetEnabled && (
                   <div className="mt-4">
@@ -531,22 +641,28 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
                       style={{ pointerEvents: isLoading ? 'none' : 'auto' }}
                     >
                       <span className="material-symbols-outlined">vpn_key</span>
-                      <span className="flex-grow-1">{t('forgot_password.forgot_password')}</span>
+                      <span className="flex-grow-1">
+                        {t('forgot_password.forgot_password')}
+                      </span>
                     </a>
                   </div>
                 )}
                 {/* Sign up link */}
                 {isRegistrationEnabled && (
                   <div className="mt-2">
-                    <a
-                      href="#register"
+                    <button
+                      type="button"
                       className="btn btn-sm btn-secondary btn-function col-10 col-sm-9 mx-auto py-1 d-flex"
                       style={{ pointerEvents: isLoading ? 'none' : 'auto' }}
                       onClick={switchForm}
                     >
-                      <span className="material-symbols-outlined">person_add</span>
-                      <span className="flex-grow-1">{t('Sign up is here')}</span>
-                    </a>
+                      <span className="material-symbols-outlined">
+                        person_add
+                      </span>
+                      <span className="flex-grow-1">
+                        {t('Sign up is here')}
+                      </span>
+                    </button>
                   </div>
                 )}
               </div>
@@ -558,10 +674,10 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
           </div>
         </div>
         <a href="https://growi.org" className="link-growi-org ps-3">
-          <span className="growi">GROWI</span><span className="org">.org</span>
+          <span className="growi">GROWI</span>
+          <span className="org">.org</span>
         </a>
       </div>
     </div>
   );
-
 };

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

@@ -1,5 +1,4 @@
 import React from 'react';
-
 import type { Scope } from '@growi/core/dist/interfaces';
 import { useTranslation } from 'next-i18next';
 import { useForm } from 'react-hook-form';
@@ -12,132 +11,151 @@ const MAX_DESCRIPTION_LENGTH = 200;
 
 type AccessTokenFormProps = {
   submitHandler: (info: IAccessTokenInfo) => Promise<void>;
-}
+};
 
 type FormInputs = {
   expiredAt: string;
   description: string;
   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>
-                )}
+                  {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 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 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 className="form-text mb-2">
-              {t('page_me_access_token.form.scope_desc')}
             </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>
-  );
-});
+    );
+  },
+);
 AccessTokenForm.displayName = 'AccessTokenForm';

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

@@ -1,62 +1,56 @@
 import React, { useState } from 'react';
-
 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';
 
 type AccessTokenListProps = {
   accessTokens: IResGetAccessToken[];
   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);
-    }
-  };
-
-  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>
                   <td colSpan={4} className="text-center">
                     {t('page_me_access_token.no_tokens_found')}
                   </td>
                 </tr>
-              )
-              : (
-                <>{
-                  accessTokens.map(token => (
+              ) : (
+                <>
+                  {accessTokens.map((token) => (
                     <tr key={token._id}>
                       <td className="text-break">{token.description}</td>
                       <td>{token.expiredAt.toString().split('T')[0]}</td>
@@ -72,34 +66,48 @@ export const AccessTokenList = React.memo((props: AccessTokenListProps): JSX.Ele
                         </button>
                       </td>
                     </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';

+ 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 { useTranslation } from 'next-i18next';
 import type { UseFormRegisterReturn } from 'react-hook-form';
 
 import { useDeviceLargerThanMd } from '~/states/ui/device';
 
-
 import styles from './AccessTokenScopeList.module.scss';
 
 const moduleClass = styles['access-token-scope-list'] ?? '';
@@ -18,7 +16,7 @@ interface scopeObject {
 interface AccessTokenScopeListProps {
   scopeObject: scopeObject;
   register: UseFormRegisterReturn<'scopes'>;
-  disabledScopes: Set<Scope>
+  disabledScopes: Set<Scope>;
   level?: number;
 }
 
@@ -31,7 +29,6 @@ export const AccessTokenScopeList: React.FC<AccessTokenScopeListProps> = ({
   disabledScopes,
   level = 1,
 }) => {
-
   const [isDeviceLargerThanMd] = useDeviceLargerThanMd();
 
   // 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" />}
               <div className="my-1 row">
                 <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>
 
@@ -76,11 +77,18 @@ export const AccessTokenScopeList: React.FC<AccessTokenScopeListProps> = ({
                 value={scopeValue as string}
                 {...register}
               />
-              <label className="form-check-label ms-2" htmlFor={scopeValue as string}>
+              <label
+                className="form-check-label ms-2"
+                htmlFor={scopeValue as string}
+              >
                 {scopeKey}
               </label>
             </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>
         );
       })}

+ 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 { SCOPE } from '@growi/core/dist/interfaces';
 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 { AccessTokenScopeList } from './AccessTokenScopeList';
@@ -21,11 +25,17 @@ type AccessTokenScopeSelectProps = {
 /**
  * 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 isAdmin = useIsAdmin();
 
-  const ScopesMap = useMemo(() => parseScopes({ scopes: SCOPE, isAdmin }), [isAdmin]);
+  const ScopesMap = useMemo(
+    () => parseScopes({ scopes: SCOPE, isAdmin }),
+    [isAdmin],
+  );
   const extractedScopes = useMemo(() => extractScopes(ScopesMap), [ScopesMap]);
 
   useEffect(() => {
@@ -35,7 +45,11 @@ export const AccessTokenScopeSelect: React.FC<AccessTokenScopeSelectProps> = ({
 
   return (
     <div className="border rounded">
-      <AccessTokenScopeList scopeObject={ScopesMap} register={register} disabledScopes={disabledScopes} />
+      <AccessTokenScopeList
+        scopeObject={ScopesMap}
+        register={register}
+        disabledScopes={disabledScopes}
+      />
     </div>
   );
 };

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

@@ -1,122 +1,147 @@
 import React, { useCallback } from 'react';
-
 import { useTranslation } from 'next-i18next';
 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 { useSWRxAccessToken } from '~/stores/personal-settings';
 
 import { AccessTokenForm } from './AccessTokenForm';
 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
-            className="btn btn-outline-secondary"
             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>
-  );
-});
+    );
+  },
+);
 
 export const AccessTokenSettings = React.memo((): JSX.Element => {
-
   const { t } = useTranslation();
 
   const [isFormOpen, setIsFormOpen] = React.useState<boolean>(false);
   const toggleFormOpen = useCallback(() => {
-    setIsFormOpen(prev => !prev);
+    setIsFormOpen((prev) => !prev);
   }, []);
 
   const [newToken, setNewToken] = React.useState<string | undefined>(undefined);
 
   const {
-    data: accessTokens, mutate, generateAccessToken, deleteAccessToken,
+    data: accessTokens,
+    mutate,
+    generateAccessToken,
+    deleteAccessToken,
   } = useSWRxAccessToken();
 
   const closeNewTokenDisplay = useCallback(() => {
     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 (
     <>
-
       <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
           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 { useTranslation } from 'next-i18next';
 
 import { useCurrentUser } from '~/states/global';
@@ -7,24 +6,27 @@ import { useCurrentUser } from '~/states/global';
 import { AccessTokenSettings } from './AccessTokenSettings';
 import { ApiTokenSettings } from './ApiTokenSettings';
 
-
 const ApiSettings = React.memo((): JSX.Element => {
-
   const { t } = useTranslation();
   const currentUser = useCurrentUser();
 
-  const shouldHideAccessTokenSettings = currentUser == null || !currentUser?.readOnly;
+  const shouldHideAccessTokenSettings =
+    currentUser == null || !currentUser?.readOnly;
 
   return (
     <>
       <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 />
       </div>
 
       {shouldHideAccessTokenSettings && (
         <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 />
         </div>
       )}

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

@@ -1,67 +1,64 @@
 import React, { useCallback } from 'react';
-
 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';
 
-
 export const ApiTokenSettings = React.memo((): JSX.Element => {
-
   const { t } = useTranslation();
-  const { data: personalSettingsData, mutate: mutateDatabaseData } = useSWRxPersonalSettings();
-
-  const submitHandler = useCallback(async() => {
+  const { data: personalSettingsData, mutate: mutateDatabaseData } =
+    useSWRxPersonalSettings();
 
+  const submitHandler = useCallback(async () => {
     try {
       await apiv3Put('/personal-setting/api-token');
       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);
     }
-
   }, [mutateDatabaseData, t]);
 
   return (
     <>
       <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">
-          {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 className="row">
         <div className="offset-lg-2 col-lg-7">
-
           <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>
-
         </div>
       </div>
 
@@ -77,8 +74,6 @@ export const ApiTokenSettings = React.memo((): JSX.Element => {
           </button>
         </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 {
   Modal,
-  ModalHeader,
   ModalBody,
   ModalFooter,
+  ModalHeader,
   Nav,
   NavLink,
   TabContent,
   TabPane,
 } 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';
 
 type Props = {
-  isOpen: boolean,
-  onClose: () => void,
-}
+  isOpen: boolean;
+  onClose: () => void;
+};
 
 /**
  * AssociateModalSubstance - Presentation component (heavy logic, rendered only when isOpen)
@@ -29,10 +31,13 @@ type AssociateModalSubstanceProps = {
   onClose: () => void;
 };
 
-const AssociateModalSubstance = (props: AssociateModalSubstanceProps): JSX.Element => {
+const AssociateModalSubstance = (
+  props: AssociateModalSubstanceProps,
+): JSX.Element => {
   const { onClose } = props;
   const { t } = useTranslation();
-  const { mutate: mutatePersonalExternalAccounts } = useSWRxPersonalExternalAccounts();
+  const { mutate: mutatePersonalExternalAccounts } =
+    useSWRxPersonalExternalAccounts();
   const { trigger: associateLdapAccount } = useAssociateLdapAccount();
 
   const [activeTab, setActiveTab] = useState(1);
@@ -45,29 +50,41 @@ const AssociateModalSubstance = (props: AssociateModalSubstanceProps): JSX.Eleme
     setPassword('');
   }, [onClose]);
 
-  const clickAddLdapAccountHandler = useCallback(async() => {
+  const clickAddLdapAccountHandler = useCallback(async () => {
     try {
       await associateLdapAccount({ username, password });
       mutatePersonalExternalAccounts();
 
       closeModalHandler();
       toastSuccess(t('security_settings.updated_general_security_setting'));
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
     }
-  }, [associateLdapAccount, closeModalHandler, mutatePersonalExternalAccounts, password, t, username]);
+  }, [
+    associateLdapAccount,
+    closeModalHandler,
+    mutatePersonalExternalAccounts,
+    password,
+    t,
+    username,
+  ]);
 
   const setTabToLdap = useCallback(() => setActiveTab(1), []);
   const setTabToGithub = useCallback(() => setActiveTab(2), []);
   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 (
     <>
       <ModalHeader toggle={onClose}>
-        { t('admin:user_management.create_external_account') }
+        {t('admin:user_management.create_external_account')}
       </ModalHeader>
       <ModalBody>
         <div>
@@ -76,7 +93,10 @@ const AssociateModalSubstance = (props: AssociateModalSubstanceProps): JSX.Eleme
               className={`${activeTab === 1 ? 'active' : ''} d-flex gap-1 align-items-center`}
               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
               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`}
               onClick={setTabToGoogle}
             >
-              <span className="growi-custom-icons">google</span> (TBD) Google OAuth
+              <span className="growi-custom-icons">google</span> (TBD) Google
+              OAuth
             </NavLink>
           </Nav>
           <TabContent activeTab={activeTab}>
@@ -100,24 +121,23 @@ const AssociateModalSubstance = (props: AssociateModalSubstanceProps): JSX.Eleme
                 onChangePassword={handlePasswordChange}
               />
             </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>
         </div>
       </ModalBody>
       <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')}
         </button>
       </ModalFooter>
@@ -132,11 +152,15 @@ const AssociateModal = (props: Props): JSX.Element => {
   const { isOpen, onClose } = props;
 
   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} />}
     </Modal>
   );
 };
 
-
 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 { 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 { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 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 => {
   const { t } = useTranslation();
   const registrationWhitelist = useAtomValue(registrationWhitelistAtom);
 
-  const {
-    data: personalSettingsInfo, error,
-  } = useSWRxPersonalSettings();
+  const { data: personalSettingsInfo, error } = useSWRxPersonalSettings();
 
   // Form state management
   const [formData, setFormData] = useState<IUser | null>(null);
@@ -30,23 +30,26 @@ export const BasicInfoSettings = (): JSX.Element => {
 
   const { trigger: updateBasicInfo, isMutating } = useUpdateBasicInfo();
 
-  const submitHandler = async() => {
+  const submitHandler = async () => {
     try {
       if (formData == null) {
         throw new Error('personalSettingsInfo is not loaded');
       }
       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 message = err.message;
       const code = err.code;
 
       if (code === 'email-is-already-in-use') {
         toastError(t('alert.email_is_already_in_use', { ns: 'commons' }));
-      }
-      else {
+      } else {
         toastError(message);
       }
     }
@@ -59,46 +62,65 @@ export const BasicInfoSettings = (): JSX.Element => {
     setFormData({ ...formData, ...updateData });
   };
 
-
   return (
     <>
-
       <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">
           <input
             className="form-control"
             type="text"
             name="userForm[name]"
             value={formData?.name || ''}
-            onChange={e => changePersonalSettingsHandler({ name: e.target.value })}
+            onChange={(e) =>
+              changePersonalSettingsHandler({ name: e.target.value })
+            }
           />
         </div>
       </div>
 
       <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">
           <input
             className="form-control"
             type="text"
             name="userForm[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 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="form-check form-check-inline me-4">
             <input
@@ -107,9 +129,16 @@ export const BasicInfoSettings = (): JSX.Element => {
               className="form-check-input"
               name="userForm[isEmailPublished]"
               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 className="form-check form-check-inline">
             <input
@@ -118,40 +147,63 @@ export const BasicInfoSettings = (): JSX.Element => {
               className="form-check-input"
               name="userForm[isEmailPublished]"
               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 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">
-          {
-            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 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">
           <input
             className="form-control"
@@ -159,7 +211,9 @@ export const BasicInfoSettings = (): JSX.Element => {
             key="slackMemberId"
             name="userForm[slackMemberId]"
             value={formData?.slackMemberId || ''}
-            onChange={e => changePersonalSettingsHandler({ slackMemberId: e.target.value })}
+            onChange={(e) =>
+              changePersonalSettingsHandler({ slackMemberId: e.target.value })
+            }
           />
         </div>
       </div>
@@ -177,7 +231,6 @@ export const BasicInfoSettings = (): JSX.Element => {
           </button>
         </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 { Themes, useNextThemes } from '~/stores-universal/use-next-themes';
 
-
 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 (
     <button
       type="button"
       onClick={onClick}
       className={`btn py-2 px-4 fw-bold border-3 ${isActive ? 'btn-outline-primary' : 'btn-outline-neutral-secondary'}`}
     >
-      { children }
+      {children}
     </button>
   );
 };
 
-
 export const ColorModeSettings = (): JSX.Element => {
   const { t } = useTranslation();
 
   const { setTheme, theme } = useNextThemes();
 
-  const isActive = useCallback((targetTheme: Themes) => {
-    return targetTheme === theme;
-  }, [theme]);
+  const isActive = useCallback(
+    (targetTheme: Themes) => {
+      return targetTheme === theme;
+    },
+    [theme],
+  );
 
   return (
     <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="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>
           </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>
           </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>{t('color_mode_settings.system')}</span>
           </ColorModeSettingsButton>
-
         </div>
 
         <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>

+ 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 { 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 { useDisassociateLdapAccount, useSWRxPersonalExternalAccounts } from '~/stores/personal-settings';
+import {
+  useDisassociateLdapAccount,
+  useSWRxPersonalExternalAccounts,
+} from '~/stores/personal-settings';
 
 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)
  */
 type DisassociateModalSubstanceProps = {
   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 { t } = useTranslation();
-  const { mutate: mutatePersonalExternalAccounts } = useSWRxPersonalExternalAccounts();
+  const { mutate: mutatePersonalExternalAccounts } =
+    useSWRxPersonalExternalAccounts();
   const { trigger: disassociateLdapAccount } = useDisassociateLdapAccount();
 
   const { providerType, accountId } = accountForDisassociate;
 
-  const disassociateAccountHandler = useCallback(async() => {
+  const disassociateAccountHandler = useCallback(async () => {
     try {
       await disassociateLdapAccount({ providerType, accountId });
       onClose();
       toastSuccess(t('security_settings.updated_general_security_setting'));
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
     }
 
     if (mutatePersonalExternalAccounts != null) {
       mutatePersonalExternalAccounts();
     }
-  }, [accountId, disassociateLdapAccount, mutatePersonalExternalAccounts, onClose, providerType, t]);
+  }, [
+    accountId,
+    disassociateLdapAccount,
+    mutatePersonalExternalAccounts,
+    onClose,
+    providerType,
+    t,
+  ]);
 
   return (
     <>
@@ -56,16 +65,31 @@ const DisassociateModalSubstance = (props: DisassociateModalSubstanceProps): Rea
         {t('personal_settings.disassociate_external_account')}
       </ModalHeader>
       <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>
       <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 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>
-          { t('Disassociate') }
+          {t('Disassociate')}
         </button>
       </ModalFooter>
     </>
@@ -90,5 +114,4 @@ const DisassociateModal = (props: Props): React.JSX.Element => {
   );
 };
 
-
 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 => {
   // const { t } = useTranslation();
@@ -21,7 +21,6 @@ export const EditorSettings = memo((): JSX.Element => {
 
   return (
     <div data-testid="grw-editor-settings">
-
       {/*
       <div className="row my-3">
         <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 { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 
-
 import { useSWRxPersonalExternalAccounts } from '~/stores/personal-settings';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
-
 import AssociateModal from './AssociateModal';
 import DisassociateModal from './DisassociateModal';
 import ExternalAccountRow from './ExternalAccountRow';
 
 class ExternalAccountLinkedMe extends React.Component {
-
   constructor(props) {
     super(props);
 
@@ -58,7 +54,7 @@ class ExternalAccountLinkedMe extends React.Component {
     return (
       <Fragment>
         <h2 className="border-bottom mt-4 pb-2 fs-4">
-          { t('admin:user_management.external_accounts') }
+          {t('admin:user_management.external_accounts')}
         </h2>
         <button
           type="button"
@@ -66,29 +62,35 @@ class ExternalAccountLinkedMe extends React.Component {
           className="btn btn-outline-secondary btn-sm pull-right mb-2"
           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
         </button>
 
         <table className="table table-bordered table-user-list">
           <thead>
             <tr>
-              <th width="120px">{ t('admin:user_management.authentication_provider') }</th>
+              <th width="120px">
+                {t('admin:user_management.authentication_provider')}
+              </th>
               <th>
                 <code>accountId</code>
               </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>
           </thead>
           <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>
         </table>
 
@@ -97,19 +99,16 @@ class ExternalAccountLinkedMe extends React.Component {
           onClose={this.closeAssociateModal}
         />
 
-        {this.state.accountForDisassociate != null
-        && (
+        {this.state.accountForDisassociate != null && (
           <DisassociateModal
             isOpen={this.state.isDisassociateModalOpen}
             onClose={this.closeDisassociateModal}
             accountForDisassociate={this.state.accountForDisassociate}
           />
         )}
-
       </Fragment>
     );
   }
-
 }
 
 ExternalAccountLinkedMe.propTypes = {
@@ -119,9 +118,16 @@ ExternalAccountLinkedMe.propTypes = {
 
 const ExternalAccountLinkedMeWrapperFC = (props) => {
   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;

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

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

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

@@ -1,16 +1,18 @@
 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 { 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 = {
-  name: string,
-  isEnabled: boolean,
-}
+  name: string;
+  isEnabled: boolean;
+};
 
 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 }];
-  return subscribeRules
-    .filter(rule => rule.name !== ruleName)
-    .concat(target);
+  return subscribeRules.filter((rule) => rule.name !== ruleName).concat(target);
 };
 
-
 const InAppNotificationSettings: FC = () => {
   const { t } = useTranslation();
   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;
 
     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 {
-      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);
-      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);
     }
-  }, [subscribeRules, setSubscribeRules, t]);
+  }, [subscribeRules, t]);
 
   useEffect(() => {
     initializeInAppNotificationSettings();
@@ -67,11 +82,13 @@ const InAppNotificationSettings: FC = () => {
 
   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="offset-md-3 col-md-6 text-start">
-          {subscribeRulesMenuItems.map(rule => (
+          {subscribeRulesMenuItems.map((rule) => (
             <div
               key={rule.name}
               className="form-check form-switch form-check-success"
@@ -81,9 +98,14 @@ const InAppNotificationSettings: FC = () => {
                 className="form-check-input"
                 id={rule.name}
                 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>
               </label>
               <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 { UISettings } from './UISettings';
 
-
 const OtherSettings = (): JSX.Element => {
-
   return (
     <>
       <div className="mt-4">

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

@@ -1,15 +1,12 @@
 import React, { useCallback } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 
 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';
 
-
 class PasswordSettings extends React.Component {
-
   constructor() {
     super();
 
@@ -24,7 +21,6 @@ class PasswordSettings extends React.Component {
 
     this.submitHandler = this.submitHandler.bind(this);
     this.onChangeOldPassword = this.onChangeOldPassword.bind(this);
-
   }
 
   async componentDidMount() {
@@ -32,12 +28,10 @@ class PasswordSettings extends React.Component {
       const res = await apiv3Get('/personal-setting/is-password-set');
       const { isPasswordSet, minPasswordLength } = res.data;
       this.setState({ isPasswordSet, minPasswordLength });
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
       this.setState({ retrieveError: err });
     }
-
   }
 
   async submitHandler() {
@@ -46,18 +40,24 @@ class PasswordSettings extends React.Component {
 
     try {
       await apiv3Put('/personal-setting/password', {
-        oldPassword, newPassword, newPasswordConfirm,
+        oldPassword,
+        newPassword,
+        newPasswordConfirm,
+      });
+      this.setState({
+        oldPassword: '',
+        newPassword: '',
+        newPasswordConfirm: '',
       });
-      this.setState({ oldPassword: '', newPassword: '', newPasswordConfirm: '' });
       if (onSubmit != null) {
         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);
     }
-
   }
 
   onChangeOldPassword(oldPassword) {
@@ -75,62 +75,100 @@ class PasswordSettings extends React.Component {
   render() {
     const { t } = this.props;
     const { newPassword, newPasswordConfirm, minPasswordLength } = this.state;
-    const isIncorrectConfirmPassword = (newPassword !== newPasswordConfirm);
+    const isIncorrectConfirmPassword = newPassword !== newPasswordConfirm;
     if (this.state.retrieveError != null) {
       throw new Error(this.state.retrieveError.message);
     }
 
     return (
       <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">
-            <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">
               <input
                 className="form-control"
                 type="password"
                 name="oldPassword"
                 value={this.state.oldPassword}
-                onChange={(e) => { this.onChangeOldPassword(e.target.value) }}
+                onChange={(e) => {
+                  this.onChangeOldPassword(e.target.value);
+                }}
               />
             </div>
           </div>
         )}
         <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">
             {/* 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 */}
-            <input type="password" autoComplete="new-password" style={{ display: 'none' }} />
+            <input
+              type="password"
+              autoComplete="new-password"
+              style={{ display: 'none' }}
+            />
             <input
               className="form-control"
               type="password"
               name="newPassword"
               value={this.state.newPassword}
-              onChange={(e) => { this.onChangeNewPassword(e.target.value) }}
+              onChange={(e) => {
+                this.onChangeNewPassword(e.target.value);
+              }}
             />
           </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">
             <input
               className="form-control"
               type="password"
               name="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>
 
@@ -150,7 +188,6 @@ class PasswordSettings extends React.Component {
       </React.Fragment>
     );
   }
-
 }
 
 PasswordSettings.propTypes = {
@@ -166,7 +203,6 @@ const PasswordSettingsWrapperFC = (props) => {
     mutatePersonalSettings();
   }, [mutatePersonalSettings]);
 
-
   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 { useTranslation } from 'next-i18next';
 
 import CustomNavAndContents from '../CustomNavigation/CustomNavAndContents';
-
 import ApiSettings from './ApiSettings';
 // import { EditorSettings } from './EditorSettings';
 import ExternalAccountLinkedMe from './ExternalAccountLinkedMe';
@@ -13,29 +10,82 @@ import OtherSettings from './OtherSettings';
 import PasswordSettings from './PasswordSettings';
 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 navTabMapping = useMemo(() => {
     return {
       user_infomation: {
-        Icon: () => <span data-testid="user-infomation-tab-button" className="material-symbols-outlined">person</span>,
+        Icon: UserInformationIcon,
         Content: UserSettings,
         i18n: t('User Information'),
       },
       external_accounts: {
-        Icon: () => <span data-testid="external-accounts-tab-button" className="material-symbols-outlined">ungroup</span>,
+        Icon: ExternalAccountsIcon,
         Content: ExternalAccountLinkedMe,
         i18n: t('admin:user_management.external_accounts'),
       },
       password_settings: {
-        Icon: () => <span data-testid="password-settings-tab-button" className="material-symbols-outlined">password</span>,
+        Icon: PasswordSettingsIcon,
         Content: PasswordSettings,
         i18n: t('Password Settings'),
       },
       api_settings: {
-        Icon: () => <span data-testid="api-settings-tab-button" className="material-symbols-outlined">api</span>,
+        Icon: ApiSettingsIcon,
         Content: ApiSettings,
         i18n: t('API Settings'),
       },
@@ -45,12 +95,12 @@ const PersonalSettings = () => {
       //   i18n: t('editor_settings.editor_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,
         i18n: t('in_app_notification_settings.in_app_notification_settings'),
       },
       other_settings: {
-        Icon: () => <span data-testid="other-settings-tab-button" className="material-symbols-outlined">settings</span>,
+        Icon: OtherSettingsIcon,
         Content: OtherSettings,
         i18n: t('Other Settings'),
       },
@@ -62,17 +112,23 @@ const PersonalSettings = () => {
     const tab = window.location.hash?.substring(1);
     let defaultTabIndex;
     Object.keys(navTabMapping).forEach((key, i) => {
-      if (key === tab) { defaultTabIndex = i }
+      if (key === tab) {
+        defaultTabIndex = i;
+      }
     });
     return defaultTabIndex;
   };
 
   return (
     <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>
   );
-
 };
 
 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 { useTranslation } from 'next-i18next';
 
 import ImageCropModal from '~/client/components/Common/ImageCropModal';
 import { apiPost, apiPostForm } from '~/client/util/apiv1-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 { generateGravatarSrc, GRAVATAR_DEFAULT } from '~/utils/gravatar';
+import { GRAVATAR_DEFAULT, generateGravatarSrc } from '~/utils/gravatar';
 
 const DEFAULT_IMAGE = '/images/icons/user.svg';
 
-
 const ProfileImageSettings = (): JSX.Element => {
   const { t } = useTranslation();
 
   const currentUser = useCurrentUser();
 
-  const [isGravatarEnabled, setGravatarEnabled] = useState(currentUser?.isGravatarEnabled);
+  const [isGravatarEnabled, setGravatarEnabled] = useState(
+    currentUser?.isGravatarEnabled,
+  );
   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?.image;
   });
 
   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 {
       await apiPost('/attachments.removeProfileImage');
 
       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);
     }
   }, [t]);
 
-  const submit = useCallback(async() => {
+  const submit = useCallback(async () => {
     try {
-      const response = await apiv3Put('/personal-setting/image-type', { isGravatarEnabled });
+      const response = await apiv3Put('/personal-setting/image-type', {
+        isGravatarEnabled,
+      });
 
       const { userData } = response.data;
       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);
     }
   }, [isGravatarEnabled, t]);
@@ -104,15 +130,42 @@ const ProfileImageSettings = (): JSX.Element => {
                 checked={isGravatarEnabled}
                 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>
-              <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>
             </div>
           </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 className="col-md-7 mt-5 mt-md-0">
@@ -127,26 +180,44 @@ const ProfileImageSettings = (): JSX.Element => {
                 checked={!isGravatarEnabled}
                 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>
             </div>
           </h5>
           <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">
               <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>
-              {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 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')}
-            </label>
+            </span>
             <div className="col-md-6 col-lg-8">
               <input
                 type="file"
@@ -175,10 +246,8 @@ const ProfileImageSettings = (): JSX.Element => {
           </button>
         </div>
       </div>
-
     </>
   );
-
 };
 
 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 { UncontrolledTooltip } from 'reactstrap';
 
 import { updateUserUISettings } from '~/client/services/user-ui-settings';
 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';
 
 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 => {
   const { t } = useTranslation();
-  const {
-    isDockMode, isCollapsedMode,
-  } = useSidebarMode();
+  const { isDockMode, isCollapsedMode } = useSidebarMode();
   const setPreferCollapsedMode = useSetPreferCollapsedMode();
   const [, setCollapsedContentsOpened] = useCollapsedContentsOpened();
 
@@ -36,15 +47,20 @@ export const UISettings = (): JSX.Element => {
     setCollapsedContentsOpened(false);
   }, [setPreferCollapsedMode, isCollapsedMode, setCollapsedContentsOpened]);
 
-  const updateButtonHandler = useCallback(async() => {
+  const updateButtonHandler = useCallback(async () => {
     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);
     }
-
   }, [isCollapsedMode, t]);
 
   const renderSidebarModeSwitch = () => {
@@ -60,7 +76,6 @@ export const UISettings = (): JSX.Element => {
               <span className="growi-custom-icons fs-6">sidebar-collapsed</span>
             </IconWithTooltip>
             <div className="form-check form-switch ms-1">
-
               <input
                 id="swSidebarMode"
                 className="form-check-input"
@@ -68,40 +83,61 @@ export const UISettings = (): JSX.Element => {
                 checked={isDockMode()}
                 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>
-            <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>
             </IconWithTooltip>
           </div>
           <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')}
             </label>
           </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 (
     <>
-      <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="col-md-6">
+          {renderSidebarModeSwitch()}
 
-          { renderSidebarModeSwitch() }
-
-          <div>
-          </div>
+          <div></div>
         </div>
       </div>
 
       <div className="row my-3">
         <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')}
           </button>
         </div>

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

@@ -1,5 +1,4 @@
 import React, { type JSX } from 'react';
-
 import { useTranslation } from 'next-i18next';
 
 import { BasicInfoSettings } from './BasicInfoSettings';
@@ -15,7 +14,9 @@ const UserSettings = React.memo((): JSX.Element => {
         <BasicInfoSettings />
       </div>
       <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 />
       </div>
     </div>

+ 16 - 11
apps/app/src/client/components/Page/DisplaySwitcher.tsx

@@ -1,20 +1,26 @@
 import type { JSX } from 'react';
-
 import dynamic from 'next/dynamic';
 
 import { useHashChangedEffect } from '~/client/services/side-effects/hash-changed';
 import { useIsEditable, useRevisionIdFromUrl } from '~/states/page';
-import { EditorMode, useEditorMode, useReservedNextCaretLine } from '~/states/ui/editor';
+import {
+  EditorMode,
+  useEditorMode,
+  useReservedNextCaretLine,
+} from '~/states/ui/editor';
 
 import { LazyRenderer } from '../Common/LazyRenderer';
 
-
 const PageEditor = dynamic(() => import('../PageEditor'), { ssr: false });
-const PageEditorReadOnly = dynamic(() => import('../PageEditor/PageEditorReadOnly').then(mod => mod.PageEditorReadOnly), { ssr: false });
-
+const PageEditorReadOnly = dynamic(
+  () =>
+    import('../PageEditor/PageEditorReadOnly').then(
+      (mod) => mod.PageEditorReadOnly,
+    ),
+  { ssr: false },
+);
 
 export const DisplaySwitcher = (): JSX.Element => {
-
   const { editorMode } = useEditorMode();
   const isEditable = useIsEditable();
   const revisionIdFromUrl = useRevisionIdFromUrl();
@@ -23,12 +29,11 @@ export const DisplaySwitcher = (): JSX.Element => {
   useReservedNextCaretLine();
 
   return (
-    <LazyRenderer shouldRender={isEditable === true && editorMode === EditorMode.Editor}>
+    <LazyRenderer
+      shouldRender={isEditable === true && editorMode === EditorMode.Editor}
+    >
       {/* Display <PageEditorReadOnly /> when the user is intentionally viewing a specific (past) revision. */}
-      { revisionIdFromUrl == null
-        ? <PageEditor />
-        : <PageEditorReadOnly />
-      }
+      {revisionIdFromUrl == null ? <PageEditor /> : <PageEditorReadOnly />}
     </LazyRenderer>
   );
 };

+ 5 - 4
apps/app/src/client/components/Page/EditablePageEffects.tsx

@@ -1,11 +1,13 @@
 import type { JSX } from 'react';
 
 import { usePageUpdatedEffect } from '~/client/services/side-effects/page-updated';
-import { useAwarenessSyncingEffect, useNewlyYjsDataSyncingEffect, useCurrentPageYjsDataAutoLoadEffect } from '~/features/collaborative-editor/side-effects';
-
+import {
+  useAwarenessSyncingEffect,
+  useCurrentPageYjsDataAutoLoadEffect,
+  useNewlyYjsDataSyncingEffect,
+} from '~/features/collaborative-editor/side-effects';
 
 export const EditablePageEffects = (): JSX.Element => {
-
   usePageUpdatedEffect();
 
   useCurrentPageYjsDataAutoLoadEffect();
@@ -13,5 +15,4 @@ export const EditablePageEffects = (): JSX.Element => {
   useAwarenessSyncingEffect();
 
   return <></>;
-
 };

+ 7 - 4
apps/app/src/client/components/Page/PageContentsUtilities.tsx

@@ -3,11 +3,10 @@ import { useTranslation } from 'next-i18next';
 import { useUpdateStateAfterSave } from '~/client/services/page-operation';
 import { useDrawioModalLauncherForView } from '~/client/services/side-effects/drawio-modal-launcher-for-view';
 import { useHandsontableModalLauncherForView } from '~/client/services/side-effects/handsontable-modal-launcher-for-view';
-import { toastSuccess, toastError, toastWarning } from '~/client/util/toastr';
+import { toastError, toastSuccess, toastWarning } from '~/client/util/toastr';
 import { PageUpdateErrorCode } from '~/interfaces/apiv3';
 import { useCurrentPageId } from '~/states/page';
 
-
 export const PageContentsUtilities = (): null => {
   const { t } = useTranslation();
 
@@ -23,7 +22,9 @@ export const PageContentsUtilities = (): null => {
     onSaveError: (errors) => {
       for (const error of errors) {
         if (error.code === PageUpdateErrorCode.CONFLICT) {
-          toastWarning(t('modal_resolve_conflict.conflicts_with_new_body_on_server_side'));
+          toastWarning(
+            t('modal_resolve_conflict.conflicts_with_new_body_on_server_side'),
+          );
           return;
         }
       }
@@ -41,7 +42,9 @@ export const PageContentsUtilities = (): null => {
     onSaveError: (errors) => {
       for (const error of errors) {
         if (error.code === PageUpdateErrorCode.CONFLICT) {
-          toastWarning(t('modal_resolve_conflict.conflicts_with_new_body_on_server_side'));
+          toastWarning(
+            t('modal_resolve_conflict.conflicts_with_new_body_on_server_side'),
+          );
           return;
         }
       }

+ 18 - 21
apps/app/src/client/components/Page/RevisionLoader.tsx

@@ -1,6 +1,5 @@
-import React, { useState, useEffect, type JSX } from 'react';
-
-import type { Ref, IRevision, IRevisionHasId } from '@growi/core';
+import React, { type JSX, useEffect, useState } from 'react';
+import type { IRevision, IRevisionHasId, Ref } from '@growi/core';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
 
@@ -8,17 +7,16 @@ import type { RendererOptions } from '~/interfaces/renderer-options';
 import { useSWRxPageRevision } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 
-
 import RevisionRenderer from '../../../components/PageView/RevisionRenderer';
 
 export const ROOT_ELEM_ID = 'revision-loader' as const;
 
 export type RevisionLoaderProps = {
-  rendererOptions: RendererOptions,
-  pageId: string,
-  revisionId: Ref<IRevision>,
-  onRevisionLoaded?: (revision: IRevisionHasId) => void,
-}
+  rendererOptions: RendererOptions;
+  pageId: string;
+  revisionId: Ref<IRevision>;
+  onRevisionLoaded?: (revision: IRevisionHasId) => void;
+};
 
 const logger = loggerFactory('growi:Page:RevisionLoader');
 
@@ -28,11 +26,13 @@ const logger = loggerFactory('growi:Page:RevisionLoader');
 export const RevisionLoader = (props: RevisionLoaderProps): JSX.Element => {
   const { t } = useTranslation();
 
-  const {
-    rendererOptions, pageId, revisionId, onRevisionLoaded,
-  } = props;
+  const { rendererOptions, pageId, revisionId, onRevisionLoaded } = props;
 
-  const { data: pageRevision, isLoading, error } = useSWRxPageRevision(pageId, revisionId);
+  const {
+    data: pageRevision,
+    isLoading,
+    error,
+  } = useSWRxPageRevision(pageId, revisionId);
 
   const [markdown, setMarkdown] = useState<string>('');
 
@@ -44,16 +44,16 @@ export const RevisionLoader = (props: RevisionLoaderProps): JSX.Element => {
         onRevisionLoaded(pageRevision);
       }
     }
-
   }, [onRevisionLoaded, pageRevision]);
 
   useEffect(() => {
     if (error != null) {
       const isForbidden = error != null && error[0].code === 'forbidden-page';
       if (isForbidden) {
-        setMarkdown(`<span className="material-symbols-outlined p-1">cancel</span>${t('not_allowed_to_see_this_page')}`);
-      }
-      else {
+        setMarkdown(
+          `<span className="material-symbols-outlined p-1">cancel</span>${t('not_allowed_to_see_this_page')}`,
+        );
+      } else {
         const errorMessages = error.map((error) => {
           return `<span className="material-symbols-outlined p-1">cancel</span><span class="text-muted"><em>${error.message}</em></span>`;
         });
@@ -73,9 +73,6 @@ export const RevisionLoader = (props: RevisionLoaderProps): JSX.Element => {
   }
 
   return (
-    <RevisionRenderer
-      rendererOptions={rendererOptions}
-      markdown={markdown}
-    />
+    <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown} />
   );
 };

+ 2 - 5
apps/app/src/client/components/Page/SlideRenderer.tsx

@@ -1,19 +1,16 @@
 import type { JSX } from 'react';
-
 import type { Options as ReactMarkdownOptions } from 'react-markdown';
 
 import { usePresentationViewOptions } from '~/stores/renderer';
 
 import { Slides } from '../Presentation/Slides';
 
-
 type SlideRendererProps = {
-  markdown: string,
-  marp?: boolean,
+  markdown: string;
+  marp?: boolean;
 };
 
 export const SlideRenderer = (props: SlideRendererProps): JSX.Element => {
-
   const { markdown, marp = false } = props;
 
   const { data: rendererOptions } = usePresentationViewOptions();

+ 6 - 1
apps/app/src/client/components/Page/markdown-drawio-util-for-view.ts

@@ -1,7 +1,12 @@
 /**
  * return markdown where the drawioData specified by line number params is replaced to the drawioData specified by drawioData param
  */
-export const replaceDrawioInMarkdown = (drawioData: string, markdown: string, beginLineNumber: number, endLineNumber: number): string => {
+export const replaceDrawioInMarkdown = (
+  drawioData: string,
+  markdown: string,
+  beginLineNumber: number,
+  endLineNumber: number,
+): string => {
   const splitMarkdown = markdown.split(/\r\n|\r|\n/);
   const markdownBeforeDrawio = splitMarkdown.slice(0, beginLineNumber - 1);
   const markdownAfterDrawio = splitMarkdown.slice(endLineNumber);

+ 17 - 5
apps/app/src/client/components/Page/markdown-table-util-for-view.ts

@@ -1,14 +1,26 @@
 import { MarkdownTable } from '@growi/editor';
 
-export const getMarkdownTableFromLine = (markdown: string, bol: number, eol: number): MarkdownTable => {
-  const tableLines = markdown.split(/\r\n|\r|\n/).slice(bol - 1, eol).join('\n');
+export const getMarkdownTableFromLine = (
+  markdown: string,
+  bol: number,
+  eol: number,
+): MarkdownTable => {
+  const tableLines = markdown
+    .split(/\r\n|\r|\n/)
+    .slice(bol - 1, eol)
+    .join('\n');
   return MarkdownTable.fromMarkdownString(tableLines);
 };
 
 /**
-   * return markdown where the markdown table specified by line number params is replaced to the markdown table specified by table param
-   */
-export const replaceMarkdownTableInMarkdown = (table: MarkdownTable, markdown: string, beginLineNumber: number, endLineNumber: number): string => {
+ * return markdown where the markdown table specified by line number params is replaced to the markdown table specified by table param
+ */
+export const replaceMarkdownTableInMarkdown = (
+  table: MarkdownTable,
+  markdown: string,
+  beginLineNumber: number,
+  endLineNumber: number,
+): string => {
   const splitMarkdown = markdown.split(/\r\n|\r|\n/);
   const markdownBeforeTable = splitMarkdown.slice(0, beginLineNumber - 1);
   const markdownAfterTable = splitMarkdown.slice(endLineNumber);

+ 31 - 30
apps/app/src/client/components/PageAttachment/DeleteAttachmentModal.tsx

@@ -1,16 +1,15 @@
-import React, {
-  useCallback, useMemo, useState,
-} from 'react';
-
+import type React from 'react';
+import { useCallback, useMemo, useState } from 'react';
 import type { IAttachmentHasId } from '@growi/core';
-import { UserPicture, LoadingSpinner } from '@growi/ui/dist/components';
+import { LoadingSpinner, UserPicture } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
-import {
-  Button, Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
+import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
-import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useDeleteAttachmentModalStatus, useDeleteAttachmentModalActions } from '~/states/ui/modal/delete-attachment';
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import {
+  useDeleteAttachmentModalActions,
+  useDeleteAttachmentModalStatus,
+} from '~/states/ui/modal/delete-attachment';
 import loggerFactory from '~/utils/logger';
 
 import { Username } from '../../../components/User/Username';
@@ -48,7 +47,7 @@ const DeleteAttachmentModalSubstance = ({
     setDeleteError('');
   }, [closeModal]);
 
-  const onClickDeleteButtonHandler = useCallback(async() => {
+  const onClickDeleteButtonHandler = useCallback(async () => {
     if (remove == null || attachment == null) {
       return;
     }
@@ -60,8 +59,7 @@ const DeleteAttachmentModalSubstance = ({
       setDeleting(false);
       closeModal();
       toastSuccess(`Delete ${attachment.originalName}`);
-    }
-    catch (err) {
+    } catch (err) {
       setDeleting(false);
       setDeleteError('Attachment could not be deleted.');
       toastError(err);
@@ -74,18 +72,25 @@ const DeleteAttachmentModalSubstance = ({
       return;
     }
 
-    const content = (attachment.fileFormat.match(/image\/.+/i))
+    const content = attachment.fileFormat.match(/image\/.+/i) ? (
       // eslint-disable-next-line @next/next/no-img-element
-      ? <img src={attachment.filePathProxied} alt="deleting image" />
-      : '';
+      <img src={attachment.filePathProxied} alt="deleting attachment" />
+    ) : (
+      ''
+    );
 
     return (
       <div className="attachment-delete-image">
         <p>
-          <span className="material-symbols-outlined">{iconByFormat(attachment.fileFormat)}</span> {attachment.originalName}
+          <span className="material-symbols-outlined">
+            {iconByFormat(attachment.fileFormat)}
+          </span>{' '}
+          {attachment.originalName}
         </p>
         <p>
-          uploaded by <UserPicture user={attachment.creator} size="sm"></UserPicture> <Username user={attachment.creator}></Username>
+          uploaded by{' '}
+          <UserPicture user={attachment.creator} size="sm"></UserPicture>{' '}
+          <Username user={attachment.creator}></Username>
         </p>
         {content}
       </div>
@@ -105,26 +110,22 @@ const DeleteAttachmentModalSubstance = ({
   return (
     <div>
       <ModalHeader tag="h4" toggle={toggleHandler} className="text-danger">
-        <span id="contained-modal-title-lg">{t('delete_attachment_modal.confirm_delete_attachment')}</span>
+        <span id="contained-modal-title-lg">
+          {t('delete_attachment_modal.confirm_delete_attachment')}
+        </span>
       </ModalHeader>
-      <ModalBody>
-        {attachmentFileFormat}
-      </ModalBody>
+      <ModalBody>{attachmentFileFormat}</ModalBody>
       <ModalFooter>
-        <div className="me-3 d-inline-block">
-          {deletingIndicator}
-        </div>
-        <Button
-          color="outline-neutral-secondary"
-          onClick={toggleHandler}
-        >
+        <div className="me-3 d-inline-block">{deletingIndicator}</div>
+        <Button color="outline-neutral-secondary" onClick={toggleHandler}>
           {t('commons:Cancel')}
         </Button>
         <Button
           color="danger"
           onClick={onClickDeleteButtonHandler}
           disabled={deleting}
-        >{t('commons:Delete')}
+        >
+          {t('commons:Delete')}
         </Button>
       </ModalFooter>
     </div>

+ 7 - 10
apps/app/src/client/components/PageAttachment/PageAttachmentList.tsx

@@ -1,22 +1,20 @@
 import React, { type JSX } from 'react';
-
 import type { IAttachmentHasId } from '@growi/core';
 import { Attachment } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
 
 type Props = {
-  attachments: (IAttachmentHasId)[],
-  inUse: { [id: string]: boolean },
-  onAttachmentDeleteClicked: (attachment: IAttachmentHasId) => void,
-  isUserLoggedIn?: boolean,
-}
+  attachments: IAttachmentHasId[];
+  inUse: { [id: string]: boolean };
+  onAttachmentDeleteClicked: (attachment: IAttachmentHasId) => void;
+  isUserLoggedIn?: boolean;
+};
 
 export const PageAttachmentList = (props: Props): JSX.Element => {
   const { t } = useTranslation();
 
-  const {
-    attachments, inUse, onAttachmentDeleteClicked, isUserLoggedIn,
-  } = props;
+  const { attachments, inUse, onAttachmentDeleteClicked, isUserLoggedIn } =
+    props;
 
   if (attachments.length === 0) {
     return <>{t('No_attachments_yet')}</>;
@@ -39,5 +37,4 @@ export const PageAttachmentList = (props: Props): JSX.Element => {
       </ul>
     </div>
   );
-
 };

+ 4 - 1
apps/app/src/client/components/PageAttachment/dynamic.tsx

@@ -10,7 +10,10 @@ export const DeleteAttachmentModalLazyLoaded = (): JSX.Element => {
 
   const DeleteAttachmentModal = useLazyLoader<DeleteAttachmentModalProps>(
     'delete-attachment-modal',
-    () => import('./DeleteAttachmentModal').then(mod => ({ default: mod.DeleteAttachmentModal })),
+    () =>
+      import('./DeleteAttachmentModal').then((mod) => ({
+        default: mod.DeleteAttachmentModal,
+      })),
     status?.isOpened ?? false,
   );
 

+ 231 - 138
apps/app/src/client/components/PageDeleteModal/PageDeleteModal.tsx

@@ -1,32 +1,30 @@
 import type { FC } from 'react';
-import React, {
-  useState, useMemo, useEffect, useCallback,
-} from 'react';
-
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
 import type { IPageInfoForEntity, IPageToDeleteWithMeta } from '@growi/core';
 import { isIPageInfoForEntity } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
+import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 import { apiPost } from '~/client/util/apiv1-client';
 import { apiv3Post } from '~/client/util/apiv3-client';
-import type { IDeleteSinglePageApiv1Result, IDeleteManyPageApiv3Result } from '~/interfaces/page';
-import { usePageDeleteModalStatus, usePageDeleteModalActions } from '~/states/ui/modal/page-delete';
+import type {
+  IDeleteManyPageApiv3Result,
+  IDeleteSinglePageApiv1Result,
+} from '~/interfaces/page';
+import {
+  usePageDeleteModalActions,
+  usePageDeleteModalStatus,
+} from '~/states/ui/modal/page-delete';
 import { useSWRxPageInfoForList } from '~/stores/page-listing';
 import loggerFactory from '~/utils/logger';
 
-
 import ApiErrorMessageList from '../PageManagement/ApiErrorMessageList';
 
 const { isTrashPage } = pagePathUtils;
 
-
 const logger = loggerFactory('growi:cli:PageDeleteModal');
 
-
 const deleteIconAndKey = {
   completely: {
     color: 'danger',
@@ -41,8 +39,14 @@ const deleteIconAndKey = {
 };
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
-const isIPageInfoForEntityForDeleteModal = (pageInfo: any | undefined): pageInfo is IPageInfoForEntity => {
-  return pageInfo != null && 'isDeletable' in pageInfo && 'isAbleToDeleteCompletely' in pageInfo;
+const isIPageInfoForEntityForDeleteModal = (
+  pageInfo: any | undefined,
+): pageInfo is IPageInfoForEntity => {
+  return (
+    pageInfo != null &&
+    'isDeletable' in pageInfo &&
+    'isAbleToDeleteCompletely' in pageInfo
+  );
 };
 
 export const PageDeleteModal: FC = () => {
@@ -51,56 +55,78 @@ export const PageDeleteModal: FC = () => {
   const { close: closeDeleteModal } = usePageDeleteModalActions();
 
   // Optimize deps: use page IDs and length instead of pages array reference
-  const pageIds = useMemo(() => pages?.map(p => p.data._id) ?? [], [pages]);
+  const pageIds = useMemo(() => pages?.map((p) => p.data._id) ?? [], [pages]);
   const pagesLength = pages?.length ?? 0;
 
-  const notOperatablePages: IPageToDeleteWithMeta[] = useMemo(() => (pages ?? []).filter(p => !isIPageInfoForEntityForDeleteModal(p.meta)),
+  // biome-ignore lint/correctness/useExhaustiveDependencies: keep optimized deps
+  const notOperatablePages: IPageToDeleteWithMeta[] = useMemo(
+    () =>
+      (pages ?? []).filter((p) => !isIPageInfoForEntityForDeleteModal(p.meta)),
     // Optimization: Use pageIds and pagesLength instead of pages array reference to avoid unnecessary re-computation
     // eslint-disable-next-line react-hooks/exhaustive-deps
-    [pageIds, pagesLength]);
+    [pageIds, pagesLength],
+  );
 
-  const notOperatablePageIds = useMemo(() => notOperatablePages.map(p => p.data._id), [notOperatablePages]);
+  const notOperatablePageIds = useMemo(
+    () => notOperatablePages.map((p) => p.data._id),
+    [notOperatablePages],
+  );
 
   const { injectTo } = useSWRxPageInfoForList(notOperatablePageIds);
 
   // inject IPageInfo to operate
-  const injectedPages = useMemo(() => {
-    if (pages != null) {
-      return injectTo(pages);
-    }
-    return null;
-  },
-  // Optimization: Use pageIds and pagesLength instead of pages array reference to avoid unnecessary re-computation
-  // eslint-disable-next-line react-hooks/exhaustive-deps
-  [pageIds, pagesLength, injectTo]);
+  // biome-ignore lint/correctness/useExhaustiveDependencies: keep optimized deps
+  const injectedPages = useMemo(
+    () => {
+      if (pages != null) {
+        return injectTo(pages);
+      }
+      return null;
+    },
+    // Optimization: Use pageIds and pagesLength instead of pages array reference to avoid unnecessary re-computation
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+    [pageIds, pagesLength, injectTo],
+  );
 
   // calculate conditions to delete
   const [isDeletable, isAbleToDeleteCompletely] = useMemo(() => {
     if (injectedPages != null && injectedPages.length > 0) {
-      const isDeletable = injectedPages.every(pageWithMeta => pageWithMeta.meta?.isDeletable);
-      const isAbleToDeleteCompletely = injectedPages.every(pageWithMeta => pageWithMeta.meta?.isAbleToDeleteCompletely);
+      const isDeletable = injectedPages.every(
+        (pageWithMeta) => pageWithMeta.meta?.isDeletable,
+      );
+      const isAbleToDeleteCompletely = injectedPages.every(
+        (pageWithMeta) => pageWithMeta.meta?.isAbleToDeleteCompletely,
+      );
       return [isDeletable, isAbleToDeleteCompletely];
     }
     return [true, true];
   }, [injectedPages]);
 
   // Optimize deps: use page paths for trash detection
-  const pagePaths = useMemo(() => pages?.map(p => p.data?.path ?? '') ?? [],
+  // biome-ignore lint/correctness/useExhaustiveDependencies: keep optimized deps
+  const pagePaths = useMemo(
+    () => pages?.map((p) => p.data?.path ?? '') ?? [],
     // Optimization: Use pageIds and pagesLength instead of pages array reference to avoid unnecessary re-computation
     // eslint-disable-next-line react-hooks/exhaustive-deps
-    [pageIds, pagesLength]);
+    [pageIds, pagesLength],
+  );
 
   // calculate condition to determine modal status
   const forceDeleteCompletelyMode = useMemo(() => {
     if (pagesLength > 0) {
-      return pagePaths.every(path => isTrashPage(path));
+      return pagePaths.every((path) => isTrashPage(path));
     }
     return false;
   }, [pagePaths, pagesLength]);
 
   const [isDeleteRecursively, setIsDeleteRecursively] = useState(true);
-  const [isDeleteCompletely, setIsDeleteCompletely] = useState(forceDeleteCompletelyMode);
-  const deleteMode = forceDeleteCompletelyMode || isDeleteCompletely ? 'completely' : 'temporary';
+  const [isDeleteCompletely, setIsDeleteCompletely] = useState(
+    forceDeleteCompletelyMode,
+  );
+  const deleteMode =
+    forceDeleteCompletelyMode || isDeleteCompletely
+      ? 'completely'
+      : 'temporary';
 
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
   const [errs, setErrs] = useState<Error[] | null>(null);
@@ -128,77 +154,95 @@ export const PageDeleteModal: FC = () => {
     setIsDeleteCompletely(!isDeleteCompletely);
   }, [forceDeleteCompletelyMode, isDeleteCompletely]);
 
-  const deletePage = useCallback(async() => {
-    if (pages == null) {
-      return;
-    }
+  // biome-ignore lint/correctness/useExhaustiveDependencies: keep optimized deps
+  const deletePage = useCallback(
+    async () => {
+      if (pages == null) {
+        return;
+      }
 
-    if (!isDeletable) {
-      logger.error('At least one page is not deletable.');
-      return;
-    }
+      if (!isDeletable) {
+        logger.error('At least one page is not deletable.');
+        return;
+      }
 
-    /*
-     * When multiple pages
-     */
-    if (pages.length > 1) {
-      try {
-        const isRecursively = isDeleteRecursively === true ? true : undefined;
-        const isCompletely = isDeleteCompletely === true ? true : undefined;
-
-        const pageIdToRevisionIdMap = {};
-        pages.forEach((p) => { pageIdToRevisionIdMap[p.data._id] = p.data.revision as string });
-
-        const { data } = await apiv3Post<IDeleteManyPageApiv3Result>('/pages/delete', {
-          pageIdToRevisionIdMap,
-          isRecursively,
-          isCompletely,
-        });
-
-        const onDeleted = opts?.onDeleted;
-        if (onDeleted != null) {
-          onDeleted(data.paths, data.isRecursively, data.isCompletely);
+      /*
+       * When multiple pages
+       */
+      if (pages.length > 1) {
+        try {
+          const isRecursively = isDeleteRecursively === true ? true : undefined;
+          const isCompletely = isDeleteCompletely === true ? true : undefined;
+
+          const pageIdToRevisionIdMap = {};
+          pages.forEach((p) => {
+            pageIdToRevisionIdMap[p.data._id] = p.data.revision as string;
+          });
+
+          const { data } = await apiv3Post<IDeleteManyPageApiv3Result>(
+            '/pages/delete',
+            {
+              pageIdToRevisionIdMap,
+              isRecursively,
+              isCompletely,
+            },
+          );
+
+          const onDeleted = opts?.onDeleted;
+          if (onDeleted != null) {
+            onDeleted(data.paths, data.isRecursively, data.isCompletely);
+          }
+          closeDeleteModal();
+        } catch (err) {
+          setErrs([err]);
         }
-        closeDeleteModal();
-      }
-      catch (err) {
-        setErrs([err]);
-      }
-    }
-    /*
-     * When single page
-     */
-    else {
-      try {
-        const recursively = isDeleteRecursively === true ? true : undefined;
-        const completely = forceDeleteCompletelyMode || isDeleteCompletely ? true : undefined;
-
-        const page = pages[0].data;
-
-        const { path, isRecursively, isCompletely } = await apiPost('/pages.remove', {
-          page_id: page._id,
-          revision_id: page.revision,
-          recursively,
-          completely,
-        }) as IDeleteSinglePageApiv1Result;
-
-        const onDeleted = opts?.onDeleted;
-        if (onDeleted != null) {
-          onDeleted(path, isRecursively, isCompletely);
+      } else {
+        /*
+         * When single page
+         */
+        try {
+          const recursively = isDeleteRecursively === true ? true : undefined;
+          const completely =
+            forceDeleteCompletelyMode || isDeleteCompletely ? true : undefined;
+
+          const page = pages[0].data;
+
+          const { path, isRecursively, isCompletely } = (await apiPost(
+            '/pages.remove',
+            {
+              page_id: page._id,
+              revision_id: page.revision,
+              recursively,
+              completely,
+            },
+          )) as IDeleteSinglePageApiv1Result;
+
+          const onDeleted = opts?.onDeleted;
+          if (onDeleted != null) {
+            onDeleted(path, isRecursively, isCompletely);
+          }
+
+          closeDeleteModal();
+        } catch (err) {
+          setErrs([err]);
         }
-
-        closeDeleteModal();
       }
-      catch (err) {
-        setErrs([err]);
-      }
-    }
-  },
-  // Optimization: Use pageIds and pagesLength instead of pages array reference to avoid unnecessary re-computation
-  // eslint-disable-next-line react-hooks/exhaustive-deps
-  [pageIds, pagesLength, isDeletable, isDeleteRecursively, isDeleteCompletely, forceDeleteCompletelyMode, opts?.onDeleted, closeDeleteModal]);
+    },
+    // Optimization: Use pageIds and pagesLength instead of pages array reference to avoid unnecessary re-computation
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+    [
+      pageIds,
+      pagesLength,
+      isDeletable,
+      isDeleteRecursively,
+      isDeleteCompletely,
+      forceDeleteCompletelyMode,
+      opts?.onDeleted,
+      closeDeleteModal,
+    ],
+  );
 
-  const deleteButtonHandler = useCallback(async() => {
+  const deleteButtonHandler = useCallback(async () => {
     await deletePage();
   }, [deletePage]);
 
@@ -213,9 +257,15 @@ export const PageDeleteModal: FC = () => {
           onChange={changeIsDeleteRecursivelyHandler}
           // disabled // Todo: enable this at https://redmine.weseek.co.jp/issues/82222
         />
-        <label className="form-label form-check-label" htmlFor="deleteRecursively">
-          { t('modal_delete.delete_recursively') }
-          <p className="form-text text-muted mt-0"> { t('modal_delete.recursively') }</p>
+        <label
+          className="form-label form-check-label"
+          htmlFor="deleteRecursively"
+        >
+          {t('modal_delete.delete_recursively')}
+          <p className="form-text text-muted mt-0">
+            {' '}
+            {t('modal_delete.recursively')}
+          </p>
         </label>
       </div>
     );
@@ -233,19 +283,30 @@ export const PageDeleteModal: FC = () => {
           checked={isDeleteCompletely}
           onChange={changeIsDeleteCompletelyHandler}
         />
-        <label className="form-label form-check-label" htmlFor="deleteCompletely">
-          { t('modal_delete.delete_completely')}
-          <p className="form-text text-muted mt-0"> { t('modal_delete.completely') }</p>
+        <label
+          className="form-label form-check-label"
+          htmlFor="deleteCompletely"
+        >
+          {t('modal_delete.delete_completely')}
+          <p className="form-text text-muted mt-0">
+            {' '}
+            {t('modal_delete.completely')}
+          </p>
         </label>
-        {!isAbleToDeleteCompletely
-        && (
+        {!isAbleToDeleteCompletely && (
           <p className="alert alert-warning p-2 my-0">
-            <span className="material-symbols-outlined">block</span>{ t('modal_delete.delete_completely_restriction') }
+            <span className="material-symbols-outlined">block</span>
+            {t('modal_delete.delete_completely_restriction')}
           </p>
         )}
       </div>
     );
-  }, [isAbleToDeleteCompletely, isDeleteCompletely, changeIsDeleteCompletelyHandler, t]);
+  }, [
+    isAbleToDeleteCompletely,
+    isDeleteCompletely,
+    changeIsDeleteCompletelyHandler,
+    t,
+  ]);
 
   const headerContent = useMemo(() => {
     if (!isOpened) {
@@ -253,43 +314,73 @@ export const PageDeleteModal: FC = () => {
     }
 
     return (
-      <span className={`text-${deleteIconAndKey[deleteMode].color} d-flex align-items-center`}>
-        <span className="material-symbols-outlined me-1">{deleteIconAndKey[deleteMode].icon}</span>
-        <b>{ t(`modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`) }</b>
+      <span
+        className={`text-${deleteIconAndKey[deleteMode].color} d-flex align-items-center`}
+      >
+        <span className="material-symbols-outlined me-1">
+          {deleteIconAndKey[deleteMode].icon}
+        </span>
+        <b>
+          {t(
+            `modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`,
+          )}
+        </b>
       </span>
     );
   }, [isOpened, deleteMode, t]);
 
+  // biome-ignore lint/correctness/useExhaustiveDependencies: keep optimized deps
   const bodyContent = useMemo(() => {
     if (!isOpened) {
       return <></>;
     }
 
     // Render page paths to delete inline for better performance
-    const renderingPages = injectedPages != null && injectedPages.length > 0 ? injectedPages : pages;
-    const pagePathsElements = renderingPages != null ? renderingPages.map(page => (
-      <p key={page.data._id} className="mb-1">
-        <code>{ page.data.path }</code>
-        { isIPageInfoForEntity(page.meta)
-          && !page.meta.isDeletable
-          && <span className="ms-3 text-danger"><strong>(CAN NOT TO DELETE)</strong></span> }
-      </p>
-    )) : <></>;
+    const renderingPages =
+      injectedPages != null && injectedPages.length > 0 ? injectedPages : pages;
+    const pagePathsElements =
+      renderingPages != null ? (
+        renderingPages.map((page) => (
+          <p key={page.data._id} className="mb-1">
+            <code>{page.data.path}</code>
+            {isIPageInfoForEntity(page.meta) && !page.meta.isDeletable && (
+              <span className="ms-3 text-danger">
+                <strong>(CAN NOT TO DELETE)</strong>
+              </span>
+            )}
+          </p>
+        ))
+      ) : (
+        <></>
+      );
 
     return (
       <>
         <div className="grw-scrollable-modal-body pb-1">
-          <label className="form-label">{ t('modal_delete.deleting_page') }:</label><br />
+          <span className="form-label">{t('modal_delete.deleting_page')}:</span>
+          <br />
           {/* Todo: change the way to show path on modal when too many pages are selected */}
           {pagePathsElements}
         </div>
-        { isDeletable && renderDeleteRecursivelyForm()}
-        { isDeletable && !forceDeleteCompletelyMode && renderDeleteCompletelyForm() }
+        {isDeletable && renderDeleteRecursivelyForm()}
+        {isDeletable &&
+          !forceDeleteCompletelyMode &&
+          renderDeleteCompletelyForm()}
       </>
     );
     // Optimization: Use direct dependencies instead of JSX.Element reference for better performance
     // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [isOpened, t, pageIds, pagesLength, injectedPages, isDeletable, renderDeleteRecursivelyForm, forceDeleteCompletelyMode, renderDeleteCompletelyForm]);
+  }, [
+    isOpened,
+    t,
+    pageIds,
+    pagesLength,
+    injectedPages,
+    isDeletable,
+    renderDeleteRecursivelyForm,
+    forceDeleteCompletelyMode,
+    renderDeleteCompletelyForm,
+  ]);
 
   const footerContent = useMemo(() => {
     if (!isOpened) {
@@ -306,25 +397,27 @@ export const PageDeleteModal: FC = () => {
           onClick={deleteButtonHandler}
           data-testid="delete-page-button"
         >
-          <span className="material-symbols-outlined me-1" aria-hidden="true">{deleteIconAndKey[deleteMode].icon}</span>
-          { t(`modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`) }
+          <span className="material-symbols-outlined me-1" aria-hidden="true">
+            {deleteIconAndKey[deleteMode].icon}
+          </span>
+          {t(
+            `modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`,
+          )}
         </button>
       </>
     );
   }, [isOpened, errs, deleteMode, isDeletable, deleteButtonHandler, t]);
 
   return (
-    <Modal size="lg" isOpen={isOpened} toggle={closeDeleteModal} data-testid="page-delete-modal">
-      <ModalHeader toggle={closeDeleteModal}>
-        {headerContent}
-      </ModalHeader>
-      <ModalBody>
-        {bodyContent}
-      </ModalBody>
-      <ModalFooter>
-        {footerContent}
-      </ModalFooter>
+    <Modal
+      size="lg"
+      isOpen={isOpened}
+      toggle={closeDeleteModal}
+      data-testid="page-delete-modal"
+    >
+      <ModalHeader toggle={closeDeleteModal}>{headerContent}</ModalHeader>
+      <ModalBody>{bodyContent}</ModalBody>
+      <ModalFooter>{footerContent}</ModalFooter>
     </Modal>
-
   );
 };

+ 4 - 1
apps/app/src/client/components/PageDeleteModal/dynamic.tsx

@@ -10,7 +10,10 @@ export const PageDeleteModalLazyLoaded = (): JSX.Element => {
 
   const PageDeleteModal = useLazyLoader<PageDeleteModalProps>(
     'page-delete-modal',
-    () => import('./PageDeleteModal').then(mod => ({ default: mod.PageDeleteModal })),
+    () =>
+      import('./PageDeleteModal').then((mod) => ({
+        default: mod.PageDeleteModal,
+      })),
     status?.isOpened ?? false,
   );
 

+ 144 - 84
apps/app/src/client/components/PageDuplicateModal/PageDuplicateModal.tsx

@@ -1,19 +1,18 @@
-import React, {
-  useState, useEffect, useCallback, useMemo,
-} from 'react';
-
+import type React from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
 import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
+import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 import { debounce } from 'throttle-debounce';
 
 import { apiv3Get, apiv3Post } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/toastr';
 import { useSiteUrl } from '~/states/global';
 import { isSearchServiceReachableAtom } from '~/states/server-configurations';
-import { usePageDuplicateModalStatus, usePageDuplicateModalActions } from '~/states/ui/modal/page-duplicate';
+import {
+  usePageDuplicateModalActions,
+  usePageDuplicateModalStatus,
+} from '~/states/ui/modal/page-duplicate';
 
 import DuplicatePathsTable from '../DuplicatedPathsTable';
 import ApiErrorMessageList from '../PageManagement/ApiErrorMessageList';
@@ -38,16 +37,32 @@ const PageDuplicateModalSubstance: React.FC = () => {
   const [subordinatedPages, setSubordinatedPages] = useState([]);
   const [existingPaths, setExistingPaths] = useState<string[]>([]);
   const [isDuplicateRecursively, setIsDuplicateRecursively] = useState(true);
-  const [isDuplicateRecursivelyWithoutExistPath, setIsDuplicateRecursivelyWithoutExistPath] = useState(true);
-  const [onlyDuplicateUserRelatedResources, setOnlyDuplicateUserRelatedResources] = useState(false);
+  const [
+    isDuplicateRecursivelyWithoutExistPath,
+    setIsDuplicateRecursivelyWithoutExistPath,
+  ] = useState(true);
+  const [
+    onlyDuplicateUserRelatedResources,
+    setOnlyDuplicateUserRelatedResources,
+  ] = useState(false);
 
   // Memoize computed values
-  const isTargetPageDuplicate = useMemo(() => existingPaths.includes(pageNameInput), [existingPaths, pageNameInput]);
-  const submitButtonEnabled = useMemo(() => (
-    existingPaths.length === 0 || (isDuplicateRecursively && isDuplicateRecursivelyWithoutExistPath)
-  ), [existingPaths.length, isDuplicateRecursively, isDuplicateRecursivelyWithoutExistPath]);
+  const isTargetPageDuplicate = useMemo(
+    () => existingPaths.includes(pageNameInput),
+    [existingPaths, pageNameInput],
+  );
+  const submitButtonEnabled = useMemo(
+    () =>
+      existingPaths.length === 0 ||
+      (isDuplicateRecursively && isDuplicateRecursivelyWithoutExistPath),
+    [
+      existingPaths.length,
+      isDuplicateRecursively,
+      isDuplicateRecursivelyWithoutExistPath,
+    ],
+  );
 
-  const updateSubordinatedList = useCallback(async() => {
+  const updateSubordinatedList = useCallback(async () => {
     if (page == null) {
       return;
     }
@@ -56,28 +71,32 @@ const PageDuplicateModalSubstance: React.FC = () => {
     try {
       const res = await apiv3Get('/pages/subordinated-list', { path });
       setSubordinatedPages(res.data.subordinatedPages);
-    }
-    catch (err) {
+    } catch (err) {
       setErrs(err);
       toastError(t('modal_duplicate.label.Failed to get subordinated pages'));
     }
   }, [page, t]);
 
-  const checkExistPaths = useCallback(async(fromPath, toPath) => {
-    if (page == null) {
-      return;
-    }
+  const checkExistPaths = useCallback(
+    async (fromPath, toPath) => {
+      if (page == null) {
+        return;
+      }
 
-    try {
-      const res = await apiv3Get<{ existPaths: string[] }>('/page/exist-paths', { fromPath, toPath });
-      const { existPaths } = res.data;
-      setExistingPaths(existPaths);
-    }
-    catch (err) {
-      setErrs(err);
-      toastError(t('modal_rename.label.Failed to get exist path'));
-    }
-  }, [page, t]);
+      try {
+        const res = await apiv3Get<{ existPaths: string[] }>(
+          '/page/exist-paths',
+          { fromPath, toPath },
+        );
+        const { existPaths } = res.data;
+        setExistingPaths(existPaths);
+      } catch (err) {
+        setErrs(err);
+        toastError(t('modal_rename.label.Failed to get exist path'));
+      }
+    },
+    [page, t],
+  );
 
   const checkExistPathsDebounce = useMemo(() => {
     return debounce(1000, checkExistPaths);
@@ -87,7 +106,7 @@ const PageDuplicateModalSubstance: React.FC = () => {
     if (isOpened && page != null && pageNameInput !== page.path) {
       checkExistPathsDebounce(page.path, pageNameInput);
     }
-  }, [isOpened, pageNameInput, subordinatedPages, checkExistPathsDebounce, page]);
+  }, [isOpened, pageNameInput, checkExistPathsDebounce, page]);
 
   const ppacInputChangeHandler = useCallback((value: string) => {
     setErrs(null);
@@ -114,7 +133,7 @@ const PageDuplicateModalSubstance: React.FC = () => {
     }
   }, [isOpened, page, updateSubordinatedList]);
 
-  const duplicate = useCallback(async() => {
+  const duplicate = useCallback(async () => {
     if (page == null) {
       return;
     }
@@ -124,7 +143,10 @@ const PageDuplicateModalSubstance: React.FC = () => {
     const { pageId, path } = page;
     try {
       const { data } = await apiv3Post('/pages/duplicate', {
-        pageId, pageNameInput, isRecursively: isDuplicateRecursively, onlyDuplicateUserRelatedResources,
+        pageId,
+        pageNameInput,
+        isRecursively: isDuplicateRecursively,
+        onlyDuplicateUserRelatedResources,
       });
       const onDuplicated = opts?.onDuplicated;
       const fromPath = path;
@@ -134,11 +156,17 @@ const PageDuplicateModalSubstance: React.FC = () => {
         onDuplicated(fromPath, toPath);
       }
       closeDuplicateModal();
-    }
-    catch (err) {
+    } catch (err) {
       setErrs(err);
     }
-  }, [closeDuplicateModal, opts?.onDuplicated, isDuplicateRecursively, page, pageNameInput, onlyDuplicateUserRelatedResources]);
+  }, [
+    closeDuplicateModal,
+    opts?.onDuplicated,
+    isDuplicateRecursively,
+    page,
+    pageNameInput,
+    onlyDuplicateUserRelatedResources,
+  ]);
 
   useEffect(() => {
     if (isOpened) {
@@ -154,10 +182,8 @@ const PageDuplicateModalSubstance: React.FC = () => {
       setIsDuplicateRecursively(true);
       setIsDuplicateRecursivelyWithoutExistPath(false);
     }, 1000);
-
   }, [isOpened]);
 
-
   const renderBodyContent = () => {
     if (!isOpened || page == null) {
       return <></>;
@@ -167,41 +193,46 @@ const PageDuplicateModalSubstance: React.FC = () => {
 
     return (
       <>
-        <div className="mt-3"><label className="form-label">{t('modal_duplicate.label.Current page name')}</label><br />
+        <div className="mt-3">
+          <span className="form-label">
+            {t('modal_duplicate.label.Current page name')}
+          </span>
+          <br />
           <code>{path}</code>
         </div>
         <div className="mt-3">
-          <label className="form-label" htmlFor="duplicatePageName">{ t('modal_duplicate.label.New page name') }</label><br />
+          <label className="form-label" htmlFor="duplicatePageName">
+            {t('modal_duplicate.label.New page name')}
+          </label>
+          <br />
           <div className="input-group">
             <div>
               <span className="input-group-text">{siteUrl}</span>
             </div>
             <div className="flex-fill">
-              {isReachable
-                ? (
-                  <PagePathAutoComplete
-                    initializedPath={path}
-                    onSubmit={duplicate}
-                    onInputChange={ppacInputChangeHandler}
-                    autoFocus
-                  />
-                )
-                : (
-                  <input
-                    type="text"
-                    value={pageNameInput}
-                    className="form-control"
-                    onChange={e => inputChangeHandler(e.target.value)}
-                    required
-                  />
-                )}
+              {isReachable ? (
+                <PagePathAutoComplete
+                  initializedPath={path}
+                  onSubmit={duplicate}
+                  onInputChange={ppacInputChangeHandler}
+                  autoFocus
+                />
+              ) : (
+                <input
+                  type="text"
+                  value={pageNameInput}
+                  className="form-control"
+                  onChange={(e) => inputChangeHandler(e.target.value)}
+                  required
+                />
+              )}
             </div>
           </div>
         </div>
 
-        { isTargetPageDuplicate && (
+        {isTargetPageDuplicate && (
           <p className="text-danger">Error: Target path is duplicated.</p>
-        ) }
+        )}
 
         <div className="form-check form-check-warning mt-3">
           <input
@@ -212,9 +243,14 @@ const PageDuplicateModalSubstance: React.FC = () => {
             checked={isDuplicateRecursively}
             onChange={changeIsDuplicateRecursivelyHandler}
           />
-          <label className="form-label form-check-label" htmlFor="cbDuplicateRecursively">
-            { t('modal_duplicate.label.Recursively') }
-            <p className="form-text text-muted my-0">{ t('modal_duplicate.help.recursive') }</p>
+          <label
+            className="form-label form-check-label"
+            htmlFor="cbDuplicateRecursively"
+          >
+            {t('modal_duplicate.label.Recursively')}
+            <p className="form-text text-muted my-0">
+              {t('modal_duplicate.help.recursive')}
+            </p>
           </label>
 
           <div className="mt-3">
@@ -226,11 +262,20 @@ const PageDuplicateModalSubstance: React.FC = () => {
                   id="cbDuplicatewithoutExistRecursively"
                   type="checkbox"
                   checked={isDuplicateRecursivelyWithoutExistPath}
-                  onChange={() => setIsDuplicateRecursivelyWithoutExistPath(!isDuplicateRecursivelyWithoutExistPath)}
+                  onChange={() =>
+                    setIsDuplicateRecursivelyWithoutExistPath(
+                      !isDuplicateRecursivelyWithoutExistPath,
+                    )
+                  }
                 />
-                <label className="form-label form-check-label" htmlFor="cbDuplicatewithoutExistRecursively">
-                  { t('modal_duplicate.label.Duplicate without exist path') }
-                  <p className="form-text text-muted my-0">{ t('modal_duplicate.help.recursive') }</p>
+                <label
+                  className="form-label form-check-label"
+                  htmlFor="cbDuplicatewithoutExistRecursively"
+                >
+                  {t('modal_duplicate.label.Duplicate without exist path')}
+                  <p className="form-text text-muted my-0">
+                    {t('modal_duplicate.help.recursive')}
+                  </p>
                 </label>
               </div>
             )}
@@ -243,17 +288,30 @@ const PageDuplicateModalSubstance: React.FC = () => {
             id="cbOnlyDuplicateUserRelatedResources"
             type="checkbox"
             checked={onlyDuplicateUserRelatedResources}
-            onChange={() => setOnlyDuplicateUserRelatedResources(!onlyDuplicateUserRelatedResources)}
+            onChange={() =>
+              setOnlyDuplicateUserRelatedResources(
+                !onlyDuplicateUserRelatedResources,
+              )
+            }
           />
-          <label className="form-label form-check-label" htmlFor="cbOnlyDuplicateUserRelatedResources">
-            { t('modal_duplicate.label.Only duplicate user related pages') }
-            <p className="form-text text-muted my-0">{ t('modal_duplicate.help.only_inherit_user_related_groups') }</p>
+          <label
+            className="form-label form-check-label"
+            htmlFor="cbOnlyDuplicateUserRelatedResources"
+          >
+            {t('modal_duplicate.label.Only duplicate user related pages')}
+            <p className="form-text text-muted my-0">
+              {t('modal_duplicate.help.only_inherit_user_related_groups')}
+            </p>
           </label>
         </div>
         <div className="mt-3">
           {isDuplicateRecursively && existingPaths.length !== 0 && (
-            <DuplicatePathsTable existingPaths={existingPaths} fromPath={path} toPath={pageNameInput} />
-          ) }
+            <DuplicatePathsTable
+              existingPaths={existingPaths}
+              fromPath={path}
+              toPath={pageNameInput}
+            />
+          )}
         </div>
       </>
     );
@@ -274,24 +332,19 @@ const PageDuplicateModalSubstance: React.FC = () => {
           onClick={duplicate}
           disabled={!submitButtonEnabled}
         >
-          { t('modal_duplicate.label.Duplicate page') }
+          {t('modal_duplicate.label.Duplicate page')}
         </button>
       </>
     );
   };
 
-
   return (
     <>
       <ModalHeader tag="h4" toggle={closeDuplicateModal}>
-        { t('modal_duplicate.label.Duplicate page') }
+        {t('modal_duplicate.label.Duplicate page')}
       </ModalHeader>
-      <ModalBody>
-        {renderBodyContent()}
-      </ModalBody>
-      <ModalFooter>
-        {renderFooterContent()}
-      </ModalFooter>
+      <ModalBody>{renderBodyContent()}</ModalBody>
+      <ModalFooter>{renderFooterContent()}</ModalFooter>
     </>
   );
 };
@@ -304,7 +357,14 @@ export const PageDuplicateModal = (): React.JSX.Element => {
   const { close: closeDuplicateModal } = usePageDuplicateModalActions();
 
   return (
-    <Modal size="lg" isOpen={isOpened} toggle={closeDuplicateModal} data-testid="page-duplicate-modal" className="grw-duplicate-page" autoFocus={false}>
+    <Modal
+      size="lg"
+      isOpen={isOpened}
+      toggle={closeDuplicateModal}
+      data-testid="page-duplicate-modal"
+      className="grw-duplicate-page"
+      autoFocus={false}
+    >
       {isOpened && <PageDuplicateModalSubstance />}
     </Modal>
   );

+ 4 - 1
apps/app/src/client/components/PageDuplicateModal/dynamic.tsx

@@ -10,7 +10,10 @@ export const PageDuplicateModalLazyLoaded = (): JSX.Element => {
 
   const PageDuplicateModal = useLazyLoader<PageDuplicateModalProps>(
     'page-duplicate-modal',
-    () => import('./PageDuplicateModal').then(mod => ({ default: mod.PageDuplicateModal })),
+    () =>
+      import('./PageDuplicateModal').then((mod) => ({
+        default: mod.PageDuplicateModal,
+      })),
     status?.isOpened ?? false,
   );
 

+ 15 - 14
apps/app/src/client/components/PageList/PageList.tsx

@@ -1,5 +1,4 @@
 import React, { type JSX } from 'react';
-
 import type { IPageInfoForEntity, IPageWithMeta } from '@growi/core';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
@@ -7,24 +6,28 @@ import { useTranslation } from 'next-i18next';
 import type { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
 
 import type { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
-
 import { PageListItemL } from './PageListItemL';
 
 import styles from './PageList.module.scss';
 
 type Props<M extends IPageInfoForEntity> = {
-  pages: IPageWithMeta<M>[],
-  isEnableActions?: boolean,
-  isReadOnlyUser: boolean,
-  forceHideMenuItems?: ForceHideMenuItems,
-  onPagesDeleted?: OnDeletedFunction,
-  onPagePutBacked?: OnPutBackedFunction,
-}
+  pages: IPageWithMeta<M>[];
+  isEnableActions?: boolean;
+  isReadOnlyUser: boolean;
+  forceHideMenuItems?: ForceHideMenuItems;
+  onPagesDeleted?: OnDeletedFunction;
+  onPagePutBacked?: OnPutBackedFunction;
+};
 
 const PageList = (props: Props<IPageInfoForEntity>): JSX.Element => {
   const { t } = useTranslation();
   const {
-    pages, isEnableActions, isReadOnlyUser, forceHideMenuItems, onPagesDeleted, onPagePutBacked,
+    pages,
+    isEnableActions,
+    isReadOnlyUser,
+    forceHideMenuItems,
+    onPagesDeleted,
+    onPagePutBacked,
   } = props;
 
   if (pages == null) {
@@ -37,7 +40,7 @@ const PageList = (props: Props<IPageInfoForEntity>): JSX.Element => {
     );
   }
 
-  const pageList = pages.map(page => (
+  const pageList = pages.map((page) => (
     <PageListItemL
       key={page.data._id}
       page={page}
@@ -59,9 +62,7 @@ const PageList = (props: Props<IPageInfoForEntity>): JSX.Element => {
 
   return (
     <div className={`page-list ${styles['page-list']}`}>
-      <ul className="page-list-ul list-group list-group-flush">
-        {pageList}
-      </ul>
+      <ul className="page-list-ul list-group list-group-flush">{pageList}</ul>
     </div>
   );
 };

+ 212 - 134
apps/app/src/client/components/PageList/PageListItemL.tsx

@@ -1,28 +1,38 @@
 import type { ForwardRefRenderFunction, JSX } from 'react';
 import React, {
-  forwardRef, useState, memo, useCallback, useImperativeHandle, useRef, useEffect,
+  forwardRef,
+  memo,
+  useCallback,
+  useEffect,
+  useImperativeHandle,
+  useRef,
+  useState,
 } from 'react';
-
+import Link from 'next/link';
 import type {
-  IPageInfoExt, IPageWithMeta, IPageInfoForListing,
+  IPageInfoExt,
+  IPageInfoForListing,
+  IPageWithMeta,
 } from '@growi/core';
-import { isIPageInfoForListing, isIPageInfoForEntity } from '@growi/core';
+import { isIPageInfoForEntity, isIPageInfoForListing } from '@growi/core';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { pathUtils } from '@growi/core/dist/utils';
-import { UserPicture, PageListMeta } from '@growi/ui/dist/components';
+import { PageListMeta, UserPicture } from '@growi/ui/dist/components';
 import { format } from 'date-fns/format';
 import { useTranslation } from 'next-i18next';
-import Link from 'next/link';
 import Clamp from 'react-multiline-clamp';
 import { Input } from 'reactstrap';
 
 import type { ISelectable } from '~/client/interfaces/selectable-all';
-import { unlink, bookmark, unbookmark } from '~/client/services/page-operation';
+import { bookmark, unbookmark, unlink } from '~/client/services/page-operation';
 import { toastError } from '~/client/util/toastr';
 import type { IPageSearchMeta, IPageWithSearchMeta } from '~/interfaces/search';
 import { isIPageSearchMeta } from '~/interfaces/search';
 import type {
-  OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction,
+  OnDeletedFunction,
+  OnDuplicatedFunction,
+  OnPutBackedFunction,
+  OnRenamedFunction,
 } from '~/interfaces/ui';
 import LinkedPagePath from '~/models/linked-page-path';
 import { useDeviceLargerThanLg } from '~/states/ui/device';
@@ -38,32 +48,47 @@ import type { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 import { PageItemControl } from '../Common/Dropdown/PageItemControl';
 
 type Props = {
-  page: IPageWithSearchMeta | IPageWithMeta<IPageInfoForListing & IPageSearchMeta>,
-  isSelected?: boolean, // is item selected(focused)
-  isEnableActions?: boolean,
-  isReadOnlyUser: boolean,
-  forceHideMenuItems?: ForceHideMenuItems,
-  showPageUpdatedTime?: boolean, // whether to show page's updated time at the top-right corner of item
-  onCheckboxChanged?: (isChecked: boolean, pageId: string) => void,
-  onClickItem?: (pageId: string) => void,
-  onPageDuplicated?: OnDuplicatedFunction,
-  onPageRenamed?: OnRenamedFunction,
-  onPageDeleted?: OnDeletedFunction,
-  onPagePutBacked?: OnPutBackedFunction,
-}
-
-const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (props: Props, ref): JSX.Element => {
+  page:
+    | IPageWithSearchMeta
+    | IPageWithMeta<IPageInfoForListing & IPageSearchMeta>;
+  isSelected?: boolean; // is item selected(focused)
+  isEnableActions?: boolean;
+  isReadOnlyUser: boolean;
+  forceHideMenuItems?: ForceHideMenuItems;
+  showPageUpdatedTime?: boolean; // whether to show page's updated time at the top-right corner of item
+  onCheckboxChanged?: (isChecked: boolean, pageId: string) => void;
+  onClickItem?: (pageId: string) => void;
+  onPageDuplicated?: OnDuplicatedFunction;
+  onPageRenamed?: OnRenamedFunction;
+  onPageDeleted?: OnDeletedFunction;
+  onPagePutBacked?: OnPutBackedFunction;
+};
+
+const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (
+  props: Props,
+  ref,
+): JSX.Element => {
   const {
-    page: { data: pageData, meta: pageMeta }, isSelected, isEnableActions, isReadOnlyUser,
+    page: { data: pageData, meta: pageMeta },
+    isSelected,
+    isEnableActions,
+    isReadOnlyUser,
     forceHideMenuItems,
     showPageUpdatedTime,
-    onClickItem, onCheckboxChanged, onPageDuplicated, onPageRenamed, onPageDeleted, onPagePutBacked,
+    onClickItem,
+    onCheckboxChanged,
+    onPageDuplicated,
+    onPageRenamed,
+    onPageDeleted,
+    onPagePutBacked,
   } = props;
 
   const { returnPathForURL } = pathUtils;
 
   const [likerCount, setLikerCount] = useState(pageData.liker.length);
-  const [bookmarkCount, setBookmarkCount] = useState(pageMeta && pageMeta.bookmarkCount ? pageMeta.bookmarkCount : 0);
+  const [bookmarkCount, setBookmarkCount] = useState(
+    pageMeta && pageMeta.bookmarkCount ? pageMeta.bookmarkCount : 0,
+  );
 
   const { t } = useTranslation();
 
@@ -92,20 +117,37 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   const { open: openPutBackPageModal } = usePutBackPageModalActions();
 
   const shouldFetch = isSelected && (pageData != null || pageMeta != null);
-  const { data: pageInfo } = useSWRxPageInfo(shouldFetch ? pageData?._id : null);
+  const { data: pageInfo } = useSWRxPageInfo(
+    shouldFetch ? pageData?._id : null,
+  );
   const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(pageData?._id ?? null);
-  const { trigger: mutateCurrentUserBookmarks } = useSWRMUTxCurrentUserBookmarks();
-  const elasticSearchResult = isIPageSearchMeta(pageMeta) ? pageMeta.elasticSearchResult : null;
-  const revisionShortBody = isIPageInfoForListing(pageMeta) ? pageMeta.revisionShortBody : null;
+  const { trigger: mutateCurrentUserBookmarks } =
+    useSWRMUTxCurrentUserBookmarks();
+  const elasticSearchResult = isIPageSearchMeta(pageMeta)
+    ? pageMeta.elasticSearchResult
+    : null;
+  const revisionShortBody = isIPageInfoForListing(pageMeta)
+    ? pageMeta.revisionShortBody
+    : null;
 
   const dPagePath: DevidedPagePath = new DevidedPagePath(pageData.path, false);
   const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
 
-  const dPagePathHighlighted: DevidedPagePath = new DevidedPagePath(elasticSearchResult?.highlightedPath || pageData.path, true);
-  const linkedPagePathHighlightedFormer = new LinkedPagePath(dPagePathHighlighted.former);
-  const linkedPagePathHighlightedLatter = new LinkedPagePath(dPagePathHighlighted.latter);
+  const dPagePathHighlighted: DevidedPagePath = new DevidedPagePath(
+    elasticSearchResult?.highlightedPath || pageData.path,
+    true,
+  );
+  const linkedPagePathHighlightedFormer = new LinkedPagePath(
+    dPagePathHighlighted.former,
+  );
+  const linkedPagePathHighlightedLatter = new LinkedPagePath(
+    dPagePathHighlighted.latter,
+  );
 
-  const lastUpdateDate = format(new Date(pageData.updatedAt), 'yyyy/MM/dd HH:mm:ss');
+  const lastUpdateDate = format(
+    new Date(pageData.updatedAt),
+    'yyyy/MM/dd HH:mm:ss',
+  );
 
   useEffect(() => {
     if (isIPageInfoForEntity(pageInfo)) {
@@ -128,7 +170,10 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
     }
   }, [isDeviceLargerThanLg, onClickItem, pageData._id]);
 
-  const bookmarkMenuItemClickHandler = async (_pageId: string, _newValue: boolean): Promise<void> => {
+  const bookmarkMenuItemClickHandler = async (
+    _pageId: string,
+    _newValue: boolean,
+  ): Promise<void> => {
     const bookmarkOperation = _newValue ? bookmark : unbookmark;
     await bookmarkOperation(_pageId);
     mutateCurrentUserBookmarks();
@@ -143,18 +188,23 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
     openDuplicateModal(page, { onDuplicated: onPageDuplicated });
   }, [onPageDuplicated, openDuplicateModal, pageData._id, pageData.path]);
 
-  const renameMenuItemClickHandler = useCallback((_id: string, pageInfo: IPageInfoExt | undefined) => {
-    const page = { data: pageData, meta: pageInfo };
-    openRenameModal(page, { onRenamed: onPageRenamed });
-  }, [pageData, onPageRenamed, openRenameModal]);
-
+  const renameMenuItemClickHandler = useCallback(
+    (_id: string, pageInfo: IPageInfoExt | undefined) => {
+      const page = { data: pageData, meta: pageInfo };
+      openRenameModal(page, { onRenamed: onPageRenamed });
+    },
+    [pageData, onPageRenamed, openRenameModal],
+  );
 
-  const deleteMenuItemClickHandler = useCallback((_id: string, pageInfo: IPageInfoExt | undefined) => {
-    const pageToDelete = { data: pageData, meta: pageInfo };
+  const deleteMenuItemClickHandler = useCallback(
+    (_id: string, pageInfo: IPageInfoExt | undefined) => {
+      const pageToDelete = { data: pageData, meta: pageInfo };
 
-    // open modal
-    openDeleteModal([pageToDelete], { onDeleted: onPageDeleted });
-  }, [pageData, openDeleteModal, onPageDeleted]);
+      // open modal
+      openDeleteModal([pageToDelete], { onDeleted: onPageDeleted });
+    },
+    [pageData, openDeleteModal, onPageDeleted],
+  );
 
   const revertMenuItemClickHandler = useCallback(async () => {
     const { _id: pageId, path } = pageData;
@@ -163,8 +213,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
       try {
         // pageData path should be `/trash/fuga` (`/trash` should be included to the prefix)
         await unlink(pageData.path);
-      }
-      catch (err) {
+      } catch (err) {
         toastError(err);
       }
 
@@ -176,94 +225,115 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
     openPutBackPageModal({ pageId, path }, { onPutBacked: putBackedHandler });
   }, [onPagePutBacked, openPutBackPageModal, pageData]);
 
-  const styleListGroupItem = (isDeviceLargerThanLg && onClickItem != null) ? 'list-group-item-action' : '';
+  const styleListGroupItem =
+    isDeviceLargerThanLg && onClickItem != null ? 'list-group-item-action' : '';
   // background color of list item changes when class "active" exists under 'list-group-item'
   const styleActive = isDeviceLargerThanLg && isSelected ? 'active' : '';
 
-  const shouldDangerouslySetInnerHTMLForPaths = elasticSearchResult != null && elasticSearchResult.highlightedPath != null;
+  const shouldDangerouslySetInnerHTMLForPaths =
+    elasticSearchResult != null && elasticSearchResult.highlightedPath != null;
 
-  const canRenderESSnippet = elasticSearchResult != null && elasticSearchResult.snippet != null;
+  const canRenderESSnippet =
+    elasticSearchResult != null && elasticSearchResult.snippet != null;
   const canRenderRevisionSnippet = revisionShortBody != null;
 
   const hasBrowsingRights = canRenderESSnippet || canRenderRevisionSnippet;
 
   return (
-    <li
-      key={pageData._id}
-      className={`list-group-item d-flex align-items-center px-3 px-md-1 ${styleListGroupItem} ${styleActive}`}
-      data-testid="page-list-item-L"
-      onClick={clickHandler}
-    >
-      <div className="text-break w-100">
-        <div className="d-flex">
-          {/* checkbox */}
-          {onCheckboxChanged != null && (
-            <div className="d-flex align-items-center justify-content-center">
-              <Input
-                type="checkbox"
-                id={`cbSelect-${pageData._id}`}
-                data-testid="cb-select"
-                innerRef={inputRef}
-                onChange={(e) => { onCheckboxChanged(e.target.checked, pageData._id) }}
-              />
-            </div>
-          )}
-
-          <div className="flex-grow-1 px-2 px-md-4">
-            <div className="d-flex justify-content-between">
-              {/* page path */}
-              <PagePathHierarchicalLink
-                linkedPagePath={linkedPagePathFormer}
-                linkedPagePathByHtml={linkedPagePathHighlightedFormer}
-              />
-              {showPageUpdatedTime && (
-                <span className="page-list-updated-at text-muted">Last update: {lastUpdateDate}</span>
-              )}
-            </div>
-            <div className="d-flex align-items-center mb-1">
-              {/* Picture */}
-              <span className="me-2 d-none d-md-block">
-                <UserPicture user={pageData.lastUpdateUser} size="md" />
-              </span>
-              {/* page title */}
-              <Clamp lines={1}>
-                <span className="h5 mb-0">
-                  {/* Use permanent links to care for pages with the same name (Cannot use page path url) */}
-                  <span className="text-break">
-                    <Link
-                      legacyBehavior
-                      href={returnPathForURL(pageData.path, pageData._id)}
-                      prefetch={false}
-                    >
-                      {shouldDangerouslySetInnerHTMLForPaths
-                        ? (
+    <li key={pageData._id}>
+      <button
+        type="button"
+        className={`list-group-item d-flex align-items-center px-3 px-md-1 text-start w-100 ${styleListGroupItem} ${styleActive}`}
+        data-testid="page-list-item-L"
+        onClick={clickHandler}
+      >
+        <div className="text-break w-100">
+          <div className="d-flex">
+            {/* checkbox */}
+            {onCheckboxChanged != null && (
+              <div className="d-flex align-items-center justify-content-center">
+                <Input
+                  type="checkbox"
+                  id={`cbSelect-${pageData._id}`}
+                  data-testid="cb-select"
+                  innerRef={inputRef}
+                  onChange={(e) => {
+                    onCheckboxChanged(e.target.checked, pageData._id);
+                  }}
+                />
+              </div>
+            )}
+
+            <div className="flex-grow-1 px-2 px-md-4">
+              <div className="d-flex justify-content-between">
+                {/* page path */}
+                <PagePathHierarchicalLink
+                  linkedPagePath={linkedPagePathFormer}
+                  linkedPagePathByHtml={linkedPagePathHighlightedFormer}
+                />
+                {showPageUpdatedTime && (
+                  <span className="page-list-updated-at text-muted">
+                    Last update: {lastUpdateDate}
+                  </span>
+                )}
+              </div>
+              <div className="d-flex align-items-center mb-1">
+                {/* Picture */}
+                <span className="me-2 d-none d-md-block">
+                  <UserPicture user={pageData.lastUpdateUser} size="md" />
+                </span>
+                {/* page title */}
+                <Clamp lines={1}>
+                  <span className="h5 mb-0">
+                    {/* Use permanent links to care for pages with the same name (Cannot use page path url) */}
+                    <span className="text-break">
+                      <Link
+                        legacyBehavior
+                        href={returnPathForURL(pageData.path, pageData._id)}
+                        prefetch={false}
+                      >
+                        {shouldDangerouslySetInnerHTMLForPaths ? (
                           <a
                             className="page-segment"
+                            href={returnPathForURL(pageData.path, pageData._id)}
                             // eslint-disable-next-line react/no-danger
-                            dangerouslySetInnerHTML={{ __html: linkedPagePathHighlightedLatter.pathName }}
+                            // biome-ignore lint/security/noDangerouslySetInnerHtml: highlight markup is sanitized
+                            dangerouslySetInnerHTML={{
+                              __html: linkedPagePathHighlightedLatter.pathName,
+                            }}
+                          ></a>
+                        ) : (
+                          <a
+                            className="page-segment"
+                            href={returnPathForURL(pageData.path, pageData._id)}
                           >
+                            {linkedPagePathHighlightedLatter.pathName}
                           </a>
-                        )
-                        : <a className="page-segment">{linkedPagePathHighlightedLatter.pathName}</a>
-                      }
-                    </Link>
+                        )}
+                      </Link>
+                    </span>
                   </span>
-                </span>
-              </Clamp>
-
-              {/* page meta */}
-              <div className="d-none d-md-flex py-0 px-1 ms-2 text-nowrap">
-                <PageListMeta page={pageData} likerCount={likerCount} bookmarkCount={bookmarkCount} shouldSpaceOutIcon />
-              </div>
-
-              {/* doropdown icon includes page control buttons */}
-              {hasBrowsingRights
-                && (
+                </Clamp>
+
+                {/* page meta */}
+                <div className="d-none d-md-flex py-0 px-1 ms-2 text-nowrap">
+                  <PageListMeta
+                    page={pageData}
+                    likerCount={likerCount}
+                    bookmarkCount={bookmarkCount}
+                    shouldSpaceOutIcon
+                  />
+                </div>
+
+                {/* doropdown icon includes page control buttons */}
+                {hasBrowsingRights && (
                   <div className="ms-auto">
                     <PageItemControl
                       alignEnd
                       pageId={pageData._id}
-                      pageInfo={isIPageInfoForListing(pageMeta) ? pageMeta : undefined}
+                      pageInfo={
+                        isIPageInfoForListing(pageMeta) ? pageMeta : undefined
+                      }
                       isEnableActions={isEnableActions}
                       isReadOnlyUser={isReadOnlyUser}
                       forceHideMenuItems={forceHideMenuItems}
@@ -274,32 +344,40 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
                       onClickRevertMenuItem={revertMenuItemClickHandler}
                     />
                   </div>
-                )
-              }
-            </div>
-            <div className="page-list-snippet py-1">
-              <Clamp lines={2}>
-                {elasticSearchResult != null && elasticSearchResult.snippet != null && (
-                  // eslint-disable-next-line react/no-danger
-                  (<div dangerouslySetInnerHTML={{ __html: elasticSearchResult.snippet }}></div>)
-                )}
-                {revisionShortBody != null && (
-                  <div data-testid="revision-short-body-in-page-list-item-L">{revisionShortBody}</div>
                 )}
-                {
-                  !hasBrowsingRights && (
+              </div>
+              <div className="page-list-snippet py-1">
+                <Clamp lines={2}>
+                  {elasticSearchResult != null &&
+                    elasticSearchResult.snippet != null && (
+                      // eslint-disable-next-line react/no-danger
+                      <div
+                        // biome-ignore lint/security/noDangerouslySetInnerHtml: snippet markup is sanitized
+                        dangerouslySetInnerHTML={{
+                          __html: elasticSearchResult.snippet,
+                        }}
+                      ></div>
+                    )}
+                  {revisionShortBody != null && (
+                    <div data-testid="revision-short-body-in-page-list-item-L">
+                      {revisionShortBody}
+                    </div>
+                  )}
+                  {!hasBrowsingRights && (
                     <>
-                      <span className="material-symbols-outlined p-1">error</span>
+                      <span className="material-symbols-outlined p-1">
+                        error
+                      </span>
                       {t('not_allowed_to_see_this_page')}
                     </>
-                  )
-                }
-              </Clamp>
+                  )}
+                </Clamp>
+              </div>
             </div>
           </div>
         </div>
         {/* TODO: adjust snippet position */}
-      </div>
+      </button>
     </li>
   );
 };

+ 23 - 19
apps/app/src/client/components/PageList/PageListItemS.tsx

@@ -1,33 +1,36 @@
 import React, { type JSX } from 'react';
-
-import type { IPageHasId } from '@growi/core';
-import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui/dist/components';
 import Link from 'next/link';
+import type { IPageHasId } from '@growi/core';
+import {
+  PageListMeta,
+  PagePathLabel,
+  UserPicture,
+} from '@growi/ui/dist/components';
 import Clamp from 'react-multiline-clamp';
 
 import styles from './PageListItemS.module.scss';
 
 type PageListItemSProps = {
-  page: IPageHasId,
-  noLink?: boolean,
-  pageTitle?: string
-  isNarrowView?: boolean,
-}
+  page: IPageHasId;
+  noLink?: boolean;
+  pageTitle?: string;
+  isNarrowView?: boolean;
+};
 
 export const PageListItemS = (props: PageListItemSProps): JSX.Element => {
-
-  const {
-    page,
-    noLink = false,
-    pageTitle,
-    isNarrowView = false,
-  } = props;
+  const { page, noLink = false, pageTitle, isNarrowView = false } = props;
 
   const path = pageTitle != null ? pageTitle : page.path;
 
-  let pagePathElement = <PagePathLabel path={path} additionalClassNames={['mx-1']} />;
+  let pagePathElement = (
+    <PagePathLabel path={path} additionalClassNames={['mx-1']} />
+  );
   if (!noLink) {
-    pagePathElement = <Link href={`/${page._id}`} className="text-break" prefetch={false}>{pagePathElement}</Link>;
+    pagePathElement = (
+      <Link href={`/${page._id}`} className="text-break" prefetch={false}>
+        {pagePathElement}
+      </Link>
+    );
   }
 
   return (
@@ -35,7 +38,9 @@ export const PageListItemS = (props: PageListItemSProps): JSX.Element => {
       <UserPicture user={page.lastUpdateUser} noLink={noLink} />
       {isNarrowView ? (
         <Clamp lines={2}>
-          <div className={`mx-1 ${styles['page-title']} ${noLink ? 'text-break' : ''}`}>
+          <div
+            className={`mx-1 ${styles['page-title']} ${noLink ? 'text-break' : ''}`}
+          >
             {pagePathElement}
           </div>
         </Clamp>
@@ -47,5 +52,4 @@ export const PageListItemS = (props: PageListItemSProps): JSX.Element => {
       </span>
     </>
   );
-
 };

+ 49 - 29
apps/app/src/client/components/PageManagement/ApiErrorMessage.jsx

@@ -1,13 +1,10 @@
 import React from 'react';
-
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 
 const ApiErrorMessage = (props) => {
   const { t } = useTranslation();
-  const {
-    errorCode, errorMessage, targetPath,
-  } = props;
+  const { errorCode, errorMessage, targetPath } = props;
 
   function reload() {
     window.location.reload();
@@ -18,71 +15,94 @@ const ApiErrorMessage = (props) => {
       case 'already_exists':
         return (
           <>
-            <strong><span className="material-symbols-outlined me-1">cancel</span>{ t('page_api_error.already_exists') }</strong>
-            <small><a href={targetPath}>{targetPath} <span className="material-symbols-outlined me-1">login</span></a></small>
+            <strong>
+              <span className="material-symbols-outlined me-1">cancel</span>
+              {t('page_api_error.already_exists')}
+            </strong>
+            <small>
+              <a href={targetPath}>
+                {targetPath}{' '}
+                <span className="material-symbols-outlined me-1">login</span>
+              </a>
+            </small>
           </>
         );
       case 'notfound_or_forbidden':
         return (
-          <strong><span className="material-symbols-outlined me-1">cancel</span>{ t('page_api_error.notfound_or_forbidden') }</strong>
+          <strong>
+            <span className="material-symbols-outlined me-1">cancel</span>
+            {t('page_api_error.notfound_or_forbidden')}
+          </strong>
         );
       case 'user_not_admin':
         return (
-          <strong><span className="material-symbols-outlined me-1">cancel</span>{ t('page_api_error.user_not_admin') }</strong>
+          <strong>
+            <span className="material-symbols-outlined me-1">cancel</span>
+            {t('page_api_error.user_not_admin')}
+          </strong>
         );
       case 'complete_deletion_not_allowed_for_user':
         return (
-          <strong><span className="material-symbols-outlined me-1">cancel</span>{ t('page_api_error.complete_deletion_not_allowed_for_user') }</strong>
+          <strong>
+            <span className="material-symbols-outlined me-1">cancel</span>
+            {t('page_api_error.complete_deletion_not_allowed_for_user')}
+          </strong>
         );
       case 'outdated':
         return (
           <>
-            <strong><span className="material-symbols-outlined me-1">lightbulb</span> { t('page_api_error.outdated') }</strong>
-            <a className="btn-link" onClick={reload}>
-              <span className="material-symbols-outlined">keyboard_double_arrow_right</span> { t('Load latest') }
-            </a>
+            <strong>
+              <span className="material-symbols-outlined me-1">lightbulb</span>{' '}
+              {t('page_api_error.outdated')}
+            </strong>
+            <button type="button" className="btn-link" onClick={reload}>
+              <span className="material-symbols-outlined">
+                keyboard_double_arrow_right
+              </span>{' '}
+              {t('Load latest')}
+            </button>
           </>
         );
       case 'invalid_path':
         return (
-          <strong><span className="material-symbols-outlined me-1">cancel</span> Invalid path</strong>
+          <strong>
+            <span className="material-symbols-outlined me-1">cancel</span>{' '}
+            Invalid path
+          </strong>
         );
       case 'single_deletion_empty_pages':
         return (
-          <strong><span className="material-symbols-outlined me-1">cancel</span>{ t('page_api_error.single_deletion_empty_pages') }</strong>
+          <strong>
+            <span className="material-symbols-outlined me-1">cancel</span>
+            {t('page_api_error.single_deletion_empty_pages')}
+          </strong>
         );
       default:
         return (
-          <strong><span className="material-symbols-outlined me-1">cancel</span> Unknown error occured</strong>
+          <strong>
+            <span className="material-symbols-outlined me-1">cancel</span>{' '}
+            Unknown error occured
+          </strong>
         );
     }
   }
 
   if (errorCode != null) {
-    return (
-      <span className="text-danger">
-        {renderMessageByErrorCode()}
-      </span>
-    );
+    return <span className="text-danger">{renderMessageByErrorCode()}</span>;
   }
 
   if (errorMessage != null) {
-    return (
-      <span className="text-danger">
-        {errorMessage}
-      </span>
-    );
+    return <span className="text-danger">{errorMessage}</span>;
   }
 
   // render null if no error has occurred
   return null;
-
 };
 
 ApiErrorMessage.propTypes = {
-  errorCode:    PropTypes.string,
+  errorCode: PropTypes.string,
   errorMessage: PropTypes.string,
-  targetPath:   PropTypes.string,
+  targetPath: PropTypes.string,
 };
 
 export default ApiErrorMessage;

+ 10 - 5
apps/app/src/client/components/PageManagement/ApiErrorMessageList.jsx

@@ -1,5 +1,4 @@
 import React from 'react';
-
 import PropTypes from 'prop-types';
 
 import { toArrayIfNot } from '~/utils/array-utils';
@@ -11,15 +10,21 @@ function ApiErrorMessageList(props) {
 
   return (
     <>
-      {errs.map(err => <ApiErrorMessage key={err.code} errorCode={err.code} errorMessage={err.message} targetPath={props.targetPath} />)}
+      {errs.map((err) => (
+        <ApiErrorMessage
+          key={err.code}
+          errorCode={err.code}
+          errorMessage={err.message}
+          targetPath={props.targetPath}
+        />
+      ))}
     </>
   );
-
 }
 
 ApiErrorMessageList.propTypes = {
-  errs:         PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
-  targetPath:   PropTypes.string,
+  errs: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
+  targetPath: PropTypes.string,
 };
 
 export default ApiErrorMessageList;

+ 24 - 11
apps/app/src/client/components/PagePathNavSticky/CollapsedParentsDropdown.tsx

@@ -1,8 +1,10 @@
-import { useMemo, type JSX } from 'react';
-
+import { type JSX, useMemo } from 'react';
 import Link from 'next/link';
 import {
-  DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
+  UncontrolledDropdown,
 } from 'reactstrap';
 
 import type LinkedPagePath from '~/models/linked-page-path';
@@ -10,11 +12,14 @@ import type LinkedPagePath from '~/models/linked-page-path';
 import styles from './CollapsedParentsDropdown.module.scss';
 
 const getAncestorPathAndPathNames = (linkedPagePath: LinkedPagePath) => {
-  const pathAndPathName: Array<{ path: string, pathName: string }> = [];
+  const pathAndPathName: Array<{ path: string; pathName: string }> = [];
   let currentLinkedPagePath = linkedPagePath;
 
   while (currentLinkedPagePath.parent != null) {
-    pathAndPathName.unshift({ path: currentLinkedPagePath.path, pathName: currentLinkedPagePath.pathName });
+    pathAndPathName.unshift({
+      path: currentLinkedPagePath.path,
+      pathName: currentLinkedPagePath.pathName,
+    });
     currentLinkedPagePath = currentLinkedPagePath.parent;
   }
 
@@ -22,22 +27,30 @@ const getAncestorPathAndPathNames = (linkedPagePath: LinkedPagePath) => {
 };
 
 type Props = {
-  linkedPagePath: LinkedPagePath,
-}
+  linkedPagePath: LinkedPagePath;
+};
 
 export const CollapsedParentsDropdown = (props: Props): JSX.Element => {
   const { linkedPagePath } = props;
 
-  const ancestorPathAndPathNames = useMemo(() => getAncestorPathAndPathNames(linkedPagePath), [linkedPagePath]);
+  const ancestorPathAndPathNames = useMemo(
+    () => getAncestorPathAndPathNames(linkedPagePath),
+    [linkedPagePath],
+  );
 
   return (
     <UncontrolledDropdown className="d-inline-block">
       <DropdownToggle color="transparent">...</DropdownToggle>
-      <DropdownMenu className={`dropdown-menu ${styles['collapsed-parents-dropdown-menu']}`} container="body">
-        {ancestorPathAndPathNames.map(data => (
+      <DropdownMenu
+        className={`dropdown-menu ${styles['collapsed-parents-dropdown-menu']}`}
+        container="body"
+      >
+        {ancestorPathAndPathNames.map((data) => (
           <DropdownItem key={data.path}>
             <Link href={data.path} legacyBehavior>
-              <a role="menuitem">{data.pathName}</a>
+              <a role="menuitem" href={data.path}>
+                {data.pathName}
+              </a>
             </Link>
           </DropdownItem>
         ))}

+ 56 - 27
apps/app/src/client/components/PagePathNavSticky/PagePathNavSticky.tsx

@@ -1,7 +1,4 @@
-import {
-  useEffect, useMemo, useRef, useState, type JSX,
-} from 'react';
-
+import { type JSX, useEffect, useMemo, useRef, useState } from 'react';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import Sticky from 'react-stickynode';
@@ -9,12 +6,15 @@ import Sticky from 'react-stickynode';
 import { usePrintMode } from '~/client/services/use-print-mode';
 import LinkedPagePath from '~/models/linked-page-path';
 import { usePageControlsX } from '~/states/ui/page';
-import { useSidebarMode, useCurrentProductNavWidth } from '~/states/ui/sidebar';
+import { useCurrentProductNavWidth, useSidebarMode } from '~/states/ui/sidebar';
 
 import { PagePathHierarchicalLink } from '../../../components/Common/PagePathHierarchicalLink';
 import type { PagePathNavLayoutProps } from '../../../components/Common/PagePathNav';
-import { PagePathNav, PagePathNavLayout, Separator } from '../../../components/Common/PagePathNav';
-
+import {
+  PagePathNav,
+  PagePathNavLayout,
+  Separator,
+} from '../../../components/Common/PagePathNav';
 import { CollapsedParentsDropdown } from './CollapsedParentsDropdown';
 
 import styles from './PagePathNavSticky.module.scss';
@@ -23,8 +23,9 @@ const moduleClass = styles['grw-page-path-nav-sticky'];
 
 const { isTrashPage } = pagePathUtils;
 
-
-export const PagePathNavSticky = (props: PagePathNavLayoutProps): JSX.Element => {
+export const PagePathNavSticky = (
+  props: PagePathNavLayoutProps,
+): JSX.Element => {
   const { pagePath, latterLinkClassName, ...rest } = props;
 
   const isPrinting = usePrintMode();
@@ -37,24 +38,36 @@ export const PagePathNavSticky = (props: PagePathNavLayoutProps): JSX.Element =>
   const [navMaxWidth, setNavMaxWidth] = useState<number | undefined>();
 
   useEffect(() => {
-    if (pageControlsX == null || pagePathNavRef.current == null || sidebarWidth == null) {
+    if (
+      pageControlsX == null ||
+      pagePathNavRef.current == null ||
+      sidebarWidth == null
+    ) {
       return;
     }
-    setNavMaxWidth(pageControlsX - pagePathNavRef.current.getBoundingClientRect().x - 10);
-  }, [pageControlsX, pagePathNavRef, sidebarWidth]);
+    setNavMaxWidth(
+      pageControlsX - pagePathNavRef.current.getBoundingClientRect().x - 10,
+    );
+  }, [pageControlsX, sidebarWidth]);
 
   useEffect(() => {
     // wait for the end of the animation of the opening and closing of the sidebar
     const timeout = setTimeout(() => {
-      if (pageControlsX == null || pagePathNavRef.current == null || sidebarMode == null) {
+      if (
+        pageControlsX == null ||
+        pagePathNavRef.current == null ||
+        sidebarMode == null
+      ) {
         return;
       }
-      setNavMaxWidth(pageControlsX - pagePathNavRef.current.getBoundingClientRect().x - 10);
+      setNavMaxWidth(
+        pageControlsX - pagePathNavRef.current.getBoundingClientRect().x - 10,
+      );
     }, 200);
     return () => {
       clearTimeout(timeout);
     };
-  }, [pageControlsX, pagePathNavRef, sidebarMode]);
+  }, [pageControlsX, sidebarMode]);
 
   const latterLink = useMemo(() => {
     const dPagePath = new DevidedPagePath(pagePath, false, true);
@@ -67,7 +80,12 @@ export const PagePathNavSticky = (props: PagePathNavLayoutProps): JSX.Element =>
     // not collapsed
     if (dPagePath.isRoot || dPagePath.isFormerRoot) {
       const linkedPagePath = new LinkedPagePath(pagePath);
-      return <PagePathHierarchicalLink linkedPagePath={linkedPagePath} isInTrash={isInTrash} />;
+      return (
+        <PagePathHierarchicalLink
+          linkedPagePath={linkedPagePath}
+          isInTrash={isInTrash}
+        />
+      );
     }
 
     // collapsed
@@ -75,7 +93,11 @@ export const PagePathNavSticky = (props: PagePathNavLayoutProps): JSX.Element =>
       <>
         <CollapsedParentsDropdown linkedPagePath={linkedPagePathFormer} />
         <Separator />
-        <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.former} isInTrash={isInTrash} />
+        <PagePathHierarchicalLink
+          linkedPagePath={linkedPagePathLatter}
+          basePath={dPagePath.former}
+          isInTrash={isInTrash}
+        />
       </>
     );
   }, [pagePath]);
@@ -84,18 +106,23 @@ export const PagePathNavSticky = (props: PagePathNavLayoutProps): JSX.Element =>
     // Controlling pointer-events
     //  1. disable pointer-events with 'pe-none'
     <div ref={pagePathNavRef}>
-      <Sticky className={moduleClass} enabled={!isPrinting} innerClass="z-2 pe-none" innerActiveClass="active z-3 mt-1">
+      <Sticky
+        className={moduleClass}
+        enabled={!isPrinting}
+        innerClass="z-2 pe-none"
+        innerActiveClass="active z-3 mt-1"
+      >
         {({ status }) => {
           const isStatusFixed = status === Sticky.STATUS_FIXED;
 
           return (
             <>
               {/*
-                * Controlling pointer-events
-                * 2. enable pointer-events with 'pe-auto' only against the children
-                *      which width is minimized by 'd-inline-block'
-                */}
-              { isStatusFixed && (
+               * Controlling pointer-events
+               * 2. enable pointer-events with 'pe-auto' only against the children
+               *      which width is minimized by 'd-inline-block'
+               */}
+              {isStatusFixed && (
                 <div className="d-inline-block pe-auto position-absolute">
                   <PagePathNavLayout
                     pagePath={pagePath}
@@ -108,10 +135,12 @@ export const PagePathNavSticky = (props: PagePathNavLayoutProps): JSX.Element =>
               )}
 
               {/*
-                * Use 'd-block' to make the children take the full width
-                * This is to improve UX when opening/closing CopyDropdown
-                */}
-              <div className={`d-block pe-auto ${isStatusFixed ? 'invisible' : ''}`}>
+               * Use 'd-block' to make the children take the full width
+               * This is to improve UX when opening/closing CopyDropdown
+               */}
+              <div
+                className={`d-block pe-auto ${isStatusFixed ? 'invisible' : ''}`}
+              >
                 <PagePathNav
                   pagePath={pagePath}
                   latterLinkClassName={latterLinkClassName}

+ 26 - 22
apps/app/src/client/components/PagePresentationModal/PagePresentationModal.tsx

@@ -1,20 +1,21 @@
-import React, { useCallback } from 'react';
-
+import type React from 'react';
+import { useCallback } from 'react';
+import dynamic from 'next/dynamic';
 import type { PresentationProps } from '@growi/presentation/dist/client';
 import { useSlidesByFrontmatter } from '@growi/presentation/dist/services';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useFullScreen } from '@growi/ui/dist/utils';
-import dynamic from 'next/dynamic';
 import type { Options as ReactMarkdownOptions } from 'react-markdown';
-import {
-  Modal, ModalBody,
-} from 'reactstrap';
+import { Modal, ModalBody } from 'reactstrap';
 
 import { useCurrentPageData } from '~/states/page';
 import { useRendererConfig } from '~/states/server-configurations';
-import { usePresentationModalActions, usePresentationModalStatus } from '~/states/ui/modal/page-presentation';
-import { useNextThemes } from '~/stores-universal/use-next-themes';
+import {
+  usePresentationModalActions,
+  usePresentationModalStatus,
+} from '~/states/ui/modal/page-presentation';
 import { usePresentationViewOptions } from '~/stores/renderer';
+import { useNextThemes } from '~/stores-universal/use-next-themes';
 
 import { RendererErrorMessage } from '../Common/RendererErrorMessage';
 
@@ -22,19 +23,18 @@ import styles from './PagePresentationModal.module.scss';
 
 const moduleClass = styles['grw-presentation-modal'] ?? '';
 
-
-const Presentation = dynamic<PresentationProps>(() => import('../Presentation/Presentation').then(mod => mod.Presentation), {
-  ssr: false,
-  loading: () => (
-    <LoadingSpinner className="text-muted fs-1" />
-  ),
-});
+const Presentation = dynamic<PresentationProps>(
+  () => import('../Presentation/Presentation').then((mod) => mod.Presentation),
+  {
+    ssr: false,
+    loading: () => <LoadingSpinner className="text-muted fs-1" />,
+  },
+);
 
 /**
  * PagePresentationModalSubstance - Heavy processing component (rendered only when modal is open)
  */
 const PagePresentationModalSubstance: React.FC = () => {
-
   const { close: closePresentationModal } = usePresentationModalActions();
 
   const { isDarkMode } = useNextThemes();
@@ -52,8 +52,7 @@ const PagePresentationModalSubstance: React.FC = () => {
   const toggleFullscreenHandler = useCallback(() => {
     if (fullscreen.active) {
       fullscreen.exit();
-    }
-    else {
+    } else {
       fullscreen.enter();
     }
   }, [fullscreen]);
@@ -76,11 +75,16 @@ const PagePresentationModalSubstance: React.FC = () => {
         >
           {fullscreen.active ? 'close_fullscreen' : 'open_in_full'}
         </button>
-        <button className="btn-close" type="button" aria-label="Close" onClick={closeHandler}></button>
+        <button
+          className="btn-close"
+          type="button"
+          aria-label="Close"
+          onClick={closeHandler}
+        ></button>
       </div>
       <ModalBody className="modal-body d-flex justify-content-center align-items-center">
-        { !isLoading && rendererOptions == null && <RendererErrorMessage />}
-        { rendererOptions != null && isEnabledMarp != null && (
+        {!isLoading && rendererOptions == null && <RendererErrorMessage />}
+        {rendererOptions != null && isEnabledMarp != null && (
           <Presentation
             options={{
               rendererOptions: rendererOptions as ReactMarkdownOptions,
@@ -94,7 +98,7 @@ const PagePresentationModalSubstance: React.FC = () => {
           >
             {markdown}
           </Presentation>
-        ) }
+        )}
       </ModalBody>
     </>
   );

+ 4 - 1
apps/app/src/client/components/PagePresentationModal/dynamic.tsx

@@ -10,7 +10,10 @@ export const PagePresentationModalLazyLoaded = (): JSX.Element => {
 
   const PagePresentationModal = useLazyLoader<PagePresentationModalProps>(
     'page-presentation-modal',
-    () => import('./PagePresentationModal').then(mod => ({ default: mod.PagePresentationModal })),
+    () =>
+      import('./PagePresentationModal').then((mod) => ({
+        default: mod.PagePresentationModal,
+      })),
     status?.isOpened ?? false,
   );
 

+ 181 - 96
apps/app/src/client/components/PageRenameModal/PageRenameModal.tsx

@@ -1,13 +1,15 @@
-import React, {
-  useState, useEffect, useCallback, useMemo,
-} from 'react';
-
+import type React from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
 import { isIPageInfoForEntity } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
 import {
-  Collapse, Modal, ModalHeader, ModalBody, ModalFooter,
+  Collapse,
+  Modal,
+  ModalBody,
+  ModalFooter,
+  ModalHeader,
 } from 'reactstrap';
 import { debounce } from 'throttle-debounce';
 
@@ -15,7 +17,10 @@ import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/toastr';
 import { useSiteUrl } from '~/states/global';
 import { isSearchServiceReachableAtom } from '~/states/server-configurations';
-import { usePageRenameModalStatus, usePageRenameModalActions } from '~/states/ui/modal/page-rename';
+import {
+  usePageRenameModalActions,
+  usePageRenameModalStatus,
+} from '~/states/ui/modal/page-rename';
 import { useSWRxPageInfo } from '~/stores/page';
 
 import DuplicatedPathsTable from '../DuplicatedPathsTable';
@@ -38,8 +43,11 @@ const PageRenameModalSubstance: React.FC = () => {
   const { close: closeRenameModal } = usePageRenameModalActions();
   const isReachable = useAtomValue(isSearchServiceReachableAtom);
 
-  const shouldFetch = isOpened && page != null && !isIPageInfoForEntity(page.meta);
-  const { data: pageInfo } = useSWRxPageInfo(shouldFetch ? page?.data._id : null);
+  const shouldFetch =
+    isOpened && page != null && !isIPageInfoForEntity(page.meta);
+  const { data: pageInfo } = useSWRxPageInfo(
+    shouldFetch ? page?.data._id : null,
+  );
 
   if (page != null && pageInfo != null) {
     page.meta = pageInfo;
@@ -56,9 +64,10 @@ const PageRenameModalSubstance: React.FC = () => {
   const [isRemainMetadata, setIsRemainMetadata] = useState(false);
   const [expandOtherOptions, setExpandOtherOptions] = useState(false);
   const [subordinatedError] = useState(null);
-  const [isMatchedWithUserHomepagePath, setIsMatchedWithUserHomepagePath] = useState(false);
+  const [isMatchedWithUserHomepagePath, setIsMatchedWithUserHomepagePath] =
+    useState(false);
 
-  const updateSubordinatedList = useCallback(async() => {
+  const updateSubordinatedList = useCallback(async () => {
     if (page == null) {
       return;
     }
@@ -67,8 +76,7 @@ const PageRenameModalSubstance: React.FC = () => {
     try {
       const res = await apiv3Get('/pages/subordinated-list', { path });
       setSubordinatedPages(res.data.subordinatedPages);
-    }
-    catch (err) {
+    } catch (err) {
       setErrs(err);
       toastError(t('modal_rename.label.Failed to get subordinated pages'));
     }
@@ -82,20 +90,37 @@ const PageRenameModalSubstance: React.FC = () => {
   }, [isOpened, page, updateSubordinatedList]);
 
   // Memoize computed values
-  const isTargetPageDuplicate = useMemo(() => existingPaths.includes(pageNameInput), [existingPaths, pageNameInput]);
-  const isV5CompatiblePage = useMemo(() => (page != null ? isV5Compatible(page.meta) : true), [page]);
+  const isTargetPageDuplicate = useMemo(
+    () => existingPaths.includes(pageNameInput),
+    [existingPaths, pageNameInput],
+  );
+  const isV5CompatiblePage = useMemo(
+    () => (page != null ? isV5Compatible(page.meta) : true),
+    [page],
+  );
 
   const canRename = useMemo(() => {
-    if (page == null || isMatchedWithUserHomepagePath || page.data.path === pageNameInput) {
+    if (
+      page == null ||
+      isMatchedWithUserHomepagePath ||
+      page.data.path === pageNameInput
+    ) {
       return false;
     }
     if (isV5CompatiblePage) {
       return existingPaths.length === 0; // v5 data
     }
     return isRenameRecursively; // v4 data
-  }, [existingPaths.length, isMatchedWithUserHomepagePath, isRenameRecursively, page, pageNameInput, isV5CompatiblePage]);
-
-  const rename = useCallback(async() => {
+  }, [
+    existingPaths.length,
+    isMatchedWithUserHomepagePath,
+    isRenameRecursively,
+    page,
+    pageNameInput,
+    isV5CompatiblePage,
+  ]);
+
+  const rename = useCallback(async () => {
     if (page == null || !canRename) {
       return;
     }
@@ -127,31 +152,44 @@ const PageRenameModalSubstance: React.FC = () => {
         onRenamed(path);
       }
       closeRenameModal();
-    }
-    catch (err) {
+    } catch (err) {
       setErrs(err);
     }
-  }, [closeRenameModal, canRename, isRemainMetadata, isRenameRecursively, isRenameRedirect, page, pageNameInput, opts?.onRenamed]);
-
-  const checkExistPaths = useCallback(async(fromPath, toPath) => {
-    if (page == null) {
-      return;
-    }
-
-    try {
-      const res = await apiv3Get<{ existPaths: string[]}>('/page/exist-paths', { fromPath, toPath });
-      const { existPaths } = res.data;
-      setExistingPaths(existPaths);
-    }
-    catch (err) {
-      // Do not toast in case of this error because debounce process may be executed after the renaming process is completed.
-      if (err.length === 1 && err[0].code === 'from-page-is-not-exist') {
+  }, [
+    closeRenameModal,
+    canRename,
+    isRemainMetadata,
+    isRenameRecursively,
+    isRenameRedirect,
+    page,
+    pageNameInput,
+    opts?.onRenamed,
+  ]);
+
+  const checkExistPaths = useCallback(
+    async (fromPath, toPath) => {
+      if (page == null) {
         return;
       }
-      setErrs(err);
-      toastError(t('modal_rename.label.Failed to get exist path'));
-    }
-  }, [page, t]);
+
+      try {
+        const res = await apiv3Get<{ existPaths: string[] }>(
+          '/page/exist-paths',
+          { fromPath, toPath },
+        );
+        const { existPaths } = res.data;
+        setExistingPaths(existPaths);
+      } catch (err) {
+        // Do not toast in case of this error because debounce process may be executed after the renaming process is completed.
+        if (err.length === 1 && err[0].code === 'from-page-is-not-exist') {
+          return;
+        }
+        setErrs(err);
+        toastError(t('modal_rename.label.Failed to get exist path'));
+      }
+    },
+    [page, t],
+  );
 
   const checkExistPathsDebounce = useMemo(() => {
     return debounce(1000, checkExistPaths);
@@ -170,7 +208,13 @@ const PageRenameModalSubstance: React.FC = () => {
       checkExistPathsDebounce(page.data.path, pageNameInput);
       checkIsUsersHomepageDebounce(pageNameInput);
     }
-  }, [isOpened, pageNameInput, subordinatedPages, checkExistPathsDebounce, page, checkIsUsersHomepageDebounce]);
+  }, [
+    isOpened,
+    pageNameInput,
+    checkExistPathsDebounce,
+    page,
+    checkIsUsersHomepageDebounce,
+  ]);
 
   const ppacInputChangeHandler = useCallback((value: string) => {
     setErrs(null);
@@ -202,7 +246,6 @@ const PageRenameModalSubstance: React.FC = () => {
       setIsRemainMetadata(false);
       setExpandOtherOptions(false);
     }, 1000);
-
   }, [isOpened, page]);
 
   const bodyContent = () => {
@@ -215,46 +258,54 @@ const PageRenameModalSubstance: React.FC = () => {
     return (
       <>
         <div className="mb-3">
-          <label className="form-label w-100">{ t('modal_rename.label.Current page name') }</label>
-          <code className="fs-6">{ path }</code>
+          <span className="form-label w-100">
+            {t('modal_rename.label.Current page name')}
+          </span>
+          <code className="fs-6">{path}</code>
         </div>
         <div className="mb-3">
-          <label htmlFor="newPageName" className="form-label w-100">{ t('modal_rename.label.New page name') }</label>
+          <label htmlFor="newPageName" className="form-label w-100">
+            {t('modal_rename.label.New page name')}
+          </label>
           <div className="input-group">
             <div>
               <span className="input-group-text">{siteUrl}</span>
             </div>
-            <form className="flex-fill" onSubmit={(e) => { e.preventDefault(); rename() }}>
-              {isReachable
-                ? (
-                  <PagePathAutoComplete
-                    initializedPath={path}
-                    onSubmit={rename}
-                    onInputChange={ppacInputChangeHandler}
-                    autoFocus
-                  />
-                )
-                : (
-                  <input
-                    type="text"
-                    value={pageNameInput}
-                    className="form-control"
-                    onChange={e => inputChangeHandler(e.target.value)}
-                    required
-                    autoFocus
-                  />
-                )}
+            <form
+              className="flex-fill"
+              onSubmit={(e) => {
+                e.preventDefault();
+                rename();
+              }}
+            >
+              {isReachable ? (
+                <PagePathAutoComplete
+                  initializedPath={path}
+                  onSubmit={rename}
+                  onInputChange={ppacInputChangeHandler}
+                />
+              ) : (
+                <input
+                  type="text"
+                  value={pageNameInput}
+                  className="form-control"
+                  onChange={(e) => inputChangeHandler(e.target.value)}
+                  required
+                />
+              )}
             </form>
           </div>
-          { isTargetPageDuplicate && (
+          {isTargetPageDuplicate && (
             <p className="text-danger">Error: Target path is duplicated.</p>
-          ) }
-          { isMatchedWithUserHomepagePath && (
-            <p className="text-danger">Error: Cannot move to directory under /user page.</p>
-          ) }
+          )}
+          {isMatchedWithUserHomepagePath && (
+            <p className="text-danger">
+              Error: Cannot move to directory under /user page.
+            </p>
+          )}
         </div>
 
-        { !isV5Compatible(page.meta) && (
+        {!isV5Compatible(page.meta) && (
           <>
             <div className="form-check form-check-warning">
               <input
@@ -265,8 +316,11 @@ const PageRenameModalSubstance: React.FC = () => {
                 checked={!isRenameRecursively}
                 onChange={() => setIsRenameRecursively(!isRenameRecursively)}
               />
-              <label className="form-label form-check-label" htmlFor="cbRenameThisPageOnly">
-                { t('modal_rename.label.Rename this page only') }
+              <label
+                className="form-label form-check-label"
+                htmlFor="cbRenameThisPageOnly"
+              >
+                {t('modal_rename.label.Rename this page only')}
               </label>
             </div>
             <div className="form-check form-check-warning mt-1">
@@ -278,21 +332,39 @@ const PageRenameModalSubstance: React.FC = () => {
                 checked={isRenameRecursively}
                 onChange={() => setIsRenameRecursively(!isRenameRecursively)}
               />
-              <label className="form-label form-check-label" htmlFor="cbForceRenameRecursively">
-                { t('modal_rename.label.Force rename all child pages') }
-                <p className="form-text text-muted mt-0">{ t('modal_rename.help.recursive') }</p>
+              <label
+                className="form-label form-check-label"
+                htmlFor="cbForceRenameRecursively"
+              >
+                {t('modal_rename.label.Force rename all child pages')}
+                <p className="form-text text-muted mt-0">
+                  {t('modal_rename.help.recursive')}
+                </p>
               </label>
               {isRenameRecursively && existingPaths.length !== 0 && (
-                <DuplicatedPathsTable existingPaths={existingPaths} fromPath={path} toPath={pageNameInput} />
-              ) }
+                <DuplicatedPathsTable
+                  existingPaths={existingPaths}
+                  fromPath={path}
+                  toPath={pageNameInput}
+                />
+              )}
             </div>
           </>
-        ) }
+        )}
 
         <p className="mt-2">
-          <button type="button" className="btn btn-link mt-2 p-0" aria-expanded="false" onClick={() => setExpandOtherOptions(!expandOtherOptions)}>
-            <span className={`material-symbols-outlined me-1 ${expandOtherOptions ? 'rotate-90' : ''}`}>navigate_next</span>
-            { t('modal_rename.label.Other options') }
+          <button
+            type="button"
+            className="btn btn-link mt-2 p-0"
+            aria-expanded="false"
+            onClick={() => setExpandOtherOptions(!expandOtherOptions)}
+          >
+            <span
+              className={`material-symbols-outlined me-1 ${expandOtherOptions ? 'rotate-90' : ''}`}
+            >
+              navigate_next
+            </span>
+            {t('modal_rename.label.Other options')}
           </button>
         </p>
         <Collapse isOpen={expandOtherOptions}>
@@ -305,9 +377,14 @@ const PageRenameModalSubstance: React.FC = () => {
               checked={isRenameRedirect}
               onChange={() => setIsRenameRedirect(!isRenameRedirect)}
             />
-            <label className="form-label form-check-label" htmlFor="cbRenameRedirect">
-              { t('modal_rename.label.Redirect') }
-              <p className="form-text text-muted mt-0">{ t('modal_rename.help.redirect') }</p>
+            <label
+              className="form-label form-check-label"
+              htmlFor="cbRenameRedirect"
+            >
+              {t('modal_rename.label.Redirect')}
+              <p className="form-text text-muted mt-0">
+                {t('modal_rename.help.redirect')}
+              </p>
             </label>
           </div>
 
@@ -320,9 +397,14 @@ const PageRenameModalSubstance: React.FC = () => {
               checked={isRemainMetadata}
               onChange={() => setIsRemainMetadata(!isRemainMetadata)}
             />
-            <label className="form-label form-check-label" htmlFor="cbRemainMetadata">
-              { t('modal_rename.label.Do not update metadata') }
-              <p className="form-text text-muted mt-0">{ t('modal_rename.help.metadata') }</p>
+            <label
+              className="form-label form-check-label"
+              htmlFor="cbRemainMetadata"
+            >
+              {t('modal_rename.label.Do not update metadata')}
+              <p className="form-text text-muted mt-0">
+                {t('modal_rename.help.metadata')}
+              </p>
             </label>
           </div>
           <div> {subordinatedError} </div>
@@ -347,7 +429,8 @@ const PageRenameModalSubstance: React.FC = () => {
           className="btn btn-primary"
           onClick={rename}
           disabled={submitButtonDisabled}
-        >Rename
+        >
+          Rename
         </button>
       </>
     );
@@ -356,14 +439,10 @@ const PageRenameModalSubstance: React.FC = () => {
   return (
     <>
       <ModalHeader tag="h4" toggle={closeRenameModal}>
-        { t('modal_rename.label.Move/Rename page') }
+        {t('modal_rename.label.Move/Rename page')}
       </ModalHeader>
-      <ModalBody>
-        {bodyContent()}
-      </ModalBody>
-      <ModalFooter>
-        {footerContent()}
-      </ModalFooter>
+      <ModalBody>{bodyContent()}</ModalBody>
+      <ModalFooter>{footerContent()}</ModalFooter>
     </>
   );
 };
@@ -376,7 +455,13 @@ export const PageRenameModal = (): React.JSX.Element => {
   const { close: closeRenameModal } = usePageRenameModalActions();
 
   return (
-    <Modal size="lg" isOpen={isOpened} toggle={closeRenameModal} data-testid="page-rename-modal" autoFocus={false}>
+    <Modal
+      size="lg"
+      isOpen={isOpened}
+      toggle={closeRenameModal}
+      data-testid="page-rename-modal"
+      autoFocus={false}
+    >
       {isOpened && <PageRenameModalSubstance />}
     </Modal>
   );

+ 4 - 1
apps/app/src/client/components/PageRenameModal/dynamic.tsx

@@ -10,7 +10,10 @@ export const PageRenameModalLazyLoaded = (): JSX.Element => {
 
   const PageRenameModal = useLazyLoader<PageRenameModalProps>(
     'page-rename-modal',
-    () => import('./PageRenameModal').then(mod => ({ default: mod.PageRenameModal })),
+    () =>
+      import('./PageRenameModal').then((mod) => ({
+        default: mod.PageRenameModal,
+      })),
     status?.isOpened ?? false,
   );
 

+ 19 - 17
apps/app/src/client/components/PageSelectModal/PageSelectModal.tsx

@@ -1,25 +1,19 @@
 import type { FC, JSX } from 'react';
-import {
-  Suspense, useState, useCallback, useMemo,
-} from 'react';
-
+import { Suspense, useCallback, useMemo, useState } from 'react';
 import { useTranslation } from 'next-i18next';
 import { dirname } from 'pathe';
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter, Button,
-} from 'reactstrap';
+import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 import { ItemsTree } from '~/features/page-tree/components';
 import { useIsGuestUser, useIsReadOnlyUser } from '~/states/context';
 import { useCurrentPageData } from '~/states/page';
 import {
-  usePageSelectModalStatus,
   usePageSelectModalActions,
+  usePageSelectModalStatus,
   useSelectedPageInModal,
 } from '~/states/ui/modal/page-select';
 
 import ItemsTreeContentSkeleton from '../ItemsTree/ItemsTreeContentSkeleton';
-
 import { TreeItemForModal, treeItemForModalSize } from './TreeItemForModal';
 
 const PageSelectModalSubstance: FC = () => {
@@ -43,7 +37,8 @@ const PageSelectModalSubstance: FC = () => {
   // Get selected page from atom
   const selectedPage = useSelectedPageInModal();
 
-  const isHierarchicalSelectionMode = opts?.isHierarchicalSelectionMode ?? false;
+  const isHierarchicalSelectionMode =
+    opts?.isHierarchicalSelectionMode ?? false;
 
   const onClickCancel = useCallback(() => {
     closeModal();
@@ -66,9 +61,10 @@ const PageSelectModalSubstance: FC = () => {
   }, [currentPage?.path]);
 
   // Memoize target path calculation
-  const targetPath = useMemo(() => (
-    selectedPage?.path || parentPagePath
-  ), [selectedPage?.path, parentPagePath]);
+  const targetPath = useMemo(
+    () => selectedPage?.path || parentPagePath,
+    [selectedPage?.path, parentPagePath],
+  );
 
   // Memoize checkbox handler
   const handleIncludeSubPageChange = useCallback(() => {
@@ -81,7 +77,9 @@ const PageSelectModalSubstance: FC = () => {
 
   return (
     <>
-      <ModalHeader toggle={closeModal}>{t('page_select_modal.select_page_location')}</ModalHeader>
+      <ModalHeader toggle={closeModal}>
+        {t('page_select_modal.select_page_location')}
+      </ModalHeader>
       <ModalBody className="p-0">
         <Suspense fallback={<ItemsTreeContentSkeleton />}>
           {/* 133px = 63px(ModalHeader) + 70px(ModalFooter) */}
@@ -105,7 +103,7 @@ const PageSelectModalSubstance: FC = () => {
         </Suspense>
       </ModalBody>
       <ModalFooter className="border-top d-flex flex-column">
-        { isHierarchicalSelectionMode && (
+        {isHierarchicalSelectionMode && (
           <div className="form-check form-check-info align-self-start ms-4">
             <input
               type="checkbox"
@@ -124,8 +122,12 @@ const PageSelectModalSubstance: FC = () => {
           </div>
         )}
         <div className="d-flex gap-2 align-self-end">
-          <Button color="secondary" onClick={onClickCancel}>{t('Cancel')}</Button>
-          <Button color="primary" onClick={onClickDone}>{t('Done')}</Button>
+          <Button color="secondary" onClick={onClickCancel}>
+            {t('Cancel')}
+          </Button>
+          <Button color="primary" onClick={onClickDone}>
+            {t('Done')}
+          </Button>
         </div>
       </ModalFooter>
     </>

+ 8 - 11
apps/app/src/client/components/PageSelectModal/TreeItemForModal.tsx

@@ -17,11 +17,7 @@ type TreeItemForModalProps = TreeItemProps & {
 };
 
 export const TreeItemForModal: FC<TreeItemForModalProps> = (props) => {
-  const {
-    item,
-    targetPathOrId,
-    onToggle,
-  } = props;
+  const { item, targetPathOrId, onToggle } = props;
 
   const page = item.getItemData();
   const selectPage = useSelectPageInModal();
@@ -32,13 +28,14 @@ export const TreeItemForModal: FC<TreeItemForModalProps> = (props) => {
   }, [page._id, page.path, targetPathOrId]);
 
   // Handle click to select this page
-  const handleClick = useCallback((selectedPage: IPageForItem) => {
-    selectPage(selectedPage);
-  }, [selectPage]);
+  const handleClick = useCallback(
+    (selectedPage: IPageForItem) => {
+      selectPage(selectedPage);
+    },
+    [selectPage],
+  );
 
-  const itemClassNames = [
-    isSelected ? 'active' : '',
-  ];
+  const itemClassNames = [isSelected ? 'active' : ''];
 
   return (
     <TreeItemLayout

+ 4 - 1
apps/app/src/client/components/PageSelectModal/dynamic.tsx

@@ -10,7 +10,10 @@ export const PageSelectModalLazyLoaded = (): JSX.Element => {
 
   const PageSelectModal = useLazyLoader<PageSelectModalProps>(
     'page-select-modal',
-    () => import('./PageSelectModal').then(mod => ({ default: mod.PageSelectModal })),
+    () =>
+      import('./PageSelectModal').then((mod) => ({
+        default: mod.PageSelectModal,
+      })),
     status?.isOpened ?? false,
   );
 

+ 14 - 18
apps/app/src/client/components/PageSideContents/PageAccessoriesControl.tsx

@@ -1,28 +1,22 @@
-import { type ReactNode, memo, type JSX } from 'react';
+import { type JSX, memo, type ReactNode } from 'react';
 
 import CountBadge from '../Common/CountBadge';
 
-
 import styles from './PageAccessoriesControl.module.scss';
 
 const moduleClass = styles['btn-page-accessories'];
 
-
 type Props = {
-  className?: string,
-  icon: ReactNode,
-  label: ReactNode,
-  count?: number,
-  offset?: number,
-  onClick?: () => void,
-}
+  className?: string;
+  icon: ReactNode;
+  label: ReactNode;
+  count?: number;
+  offset?: number;
+  onClick?: () => void;
+};
 
 export const PageAccessoriesControl = memo((props: Props): JSX.Element => {
-  const {
-    icon, label, count, offset,
-    className,
-    onClick,
-  } = props;
+  const { icon, label, count, offset, className, onClick } = props;
 
   return (
     <button
@@ -34,9 +28,11 @@ export const PageAccessoriesControl = memo((props: Props): JSX.Element => {
       <span className="grw-labels d-none d-lg-flex">
         {label}
         {/* Do not display CountBadge if '/trash/*': https://github.com/growilabs/growi/pull/7600 */}
-        {count != null
-          ? <CountBadge count={count} offset={offset} />
-          : <div className="px-2"></div>}
+        {count != null ? (
+          <CountBadge count={count} offset={offset} />
+        ) : (
+          <div className="px-2"></div>
+        )}
       </span>
     </button>
   );

+ 50 - 32
apps/app/src/client/components/PageSideContents/PageSideContents.tsx

@@ -1,16 +1,10 @@
-import React, {
-  Suspense,
-  useCallback,
-  useRef,
-  type JSX,
-} from 'react';
-
+import React, { type JSX, Suspense, useCallback, useRef } from 'react';
+import dynamic from 'next/dynamic';
 import type { IPagePopulatedToShowRevision } from '@growi/core';
 import { isIPageInfoForOperation } from '@growi/core/dist/interfaces';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
-import dynamic from 'next/dynamic';
 import { scroller } from 'react-scroll';
 
 import { useIsGuestUser, useIsReadOnlyUser } from '~/states/context';
@@ -23,24 +17,27 @@ import { useSWRxPageInfo, useSWRxTagsInfo } from '~/stores/page';
 import { ContentLinkButtons } from '../ContentLinkButtons';
 import { PageTagsSkeleton } from '../PageTags';
 import TableOfContents from '../TableOfContents';
-
 import { PageAccessoriesControl } from './PageAccessoriesControl';
 
-
 const { isTopPage, isUsersHomepage, isTrashPage } = pagePathUtils;
 
+const PageTags = dynamic(
+  () => import('../PageTags').then((mod) => mod.PageTags),
+  {
+    ssr: false,
+    loading: PageTagsSkeleton,
+  },
+);
 
-const PageTags = dynamic(() => import('../PageTags').then(mod => mod.PageTags), {
-  ssr: false,
-  loading: PageTagsSkeleton,
-});
-
-const AuthorInfo = dynamic(() => import('~/client/components/AuthorInfo').then(mod => mod.AuthorInfo), { ssr: false });
+const AuthorInfo = dynamic(
+  () => import('~/client/components/AuthorInfo').then((mod) => mod.AuthorInfo),
+  { ssr: false },
+);
 
 type TagsProps = {
-  pageId: string,
-  revisionId: string,
-}
+  pageId: string;
+  revisionId: string;
+};
 
 const Tags = (props: TagsProps): JSX.Element => {
   const { pageId, revisionId } = props;
@@ -76,16 +73,16 @@ const Tags = (props: TagsProps): JSX.Element => {
   );
 };
 
-
 type PageSideContentsProps = {
-  page: IPagePopulatedToShowRevision,
-  isSharedUser?: boolean,
-}
+  page: IPagePopulatedToShowRevision;
+  isSharedUser?: boolean;
+};
 
 export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
   const { t } = useTranslation();
 
-  const { open: openDescendantPageListModal } = useDescendantsPageListModalActions();
+  const { open: openDescendantPageListModal } =
+    useDescendantsPageListModalActions();
 
   const { page, isSharedUser } = props;
 
@@ -94,9 +91,7 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
   const { data: pageInfo } = useSWRxPageInfo(page._id);
   const showPageSideAuthors = useAtomValue(showPageSideAuthorsAtom);
 
-  const {
-    creator, lastUpdateUser, createdAt, updatedAt,
-  } = page;
+  const { creator, lastUpdateUser, createdAt, updatedAt } = page;
 
   const pagePath = page.path;
   const isTopPagePath = isTopPage(pagePath);
@@ -108,8 +103,18 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
       {/* AuthorInfo */}
       {showPageSideAuthors && (
         <div className="d-none d-md-block page-meta border-bottom pb-2 ms-lg-3 mb-3">
-          <AuthorInfo user={creator} date={createdAt} mode="create" locate="pageSide" />
-          <AuthorInfo user={lastUpdateUser} date={updatedAt} mode="update" locate="pageSide" />
+          <AuthorInfo
+            user={creator}
+            date={createdAt}
+            mode="create"
+            locate="pageSide"
+          />
+          <AuthorInfo
+            user={lastUpdateUser}
+            date={updatedAt}
+            mode="update"
+            locate="pageSide"
+          />
         </div>
       )}
 
@@ -130,7 +135,11 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
               icon={<span className="material-symbols-outlined">subject</span>}
               label={t('page_list')}
               // Do not display CountBadge if '/trash/*': https://github.com/growilabs/growi/pull/7600
-              count={!isTrash && isIPageInfoForOperation(pageInfo) ? pageInfo.descendantCount : undefined}
+              count={
+                !isTrash && isIPageInfoForOperation(pageInfo)
+                  ? pageInfo.descendantCount
+                  : undefined
+              }
               offset={1}
               onClick={() => openDescendantPageListModal(pagePath)}
             />
@@ -143,8 +152,17 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
             <PageAccessoriesControl
               icon={<span className="material-symbols-outlined">chat</span>}
               label={t('comments')}
-              count={isIPageInfoForOperation(pageInfo) ? pageInfo.commentCount : undefined}
-              onClick={() => scroller.scrollTo('comments-container', { smooth: false, offset: -120 })}
+              count={
+                isIPageInfoForOperation(pageInfo)
+                  ? pageInfo.commentCount
+                  : undefined
+              }
+              onClick={() =>
+                scroller.scrollTo('comments-container', {
+                  smooth: false,
+                  offset: -120,
+                })
+              }
             />
           </div>
         )}

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

@@ -1,31 +1,29 @@
 import type { FC, JSX } from 'react';
 import React, { useState } from 'react';
-
 import { useTranslation } from 'next-i18next';
 
 import { NotAvailableForGuest } from '../NotAvailableForGuest';
 import { NotAvailableForReadOnlyUser } from '../NotAvailableForReadOnlyUser';
 import { Skeleton } from '../Skeleton';
-
 import RenderTagLabels from './RenderTagLabels';
 
 import styles from './TagLabels.module.scss';
 
 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 => {
-  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 { t } = useTranslation();
 
@@ -39,8 +37,10 @@ export const PageTags:FC<Props> = (props: Props) => {
   const onMouseLeaveHandler = () => setIsHovered(false);
 
   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 */}
       <div className="d-flex d-lg-none">
         <NotAvailableForGuest>
@@ -70,7 +70,7 @@ export const PageTags:FC<Props> = (props: Props) => {
           >
             <span className="material-symbols-outlined me-1">local_offer</span>
             <span className="me-2">{t('Tags')}</span>
-            {(isHovered && !isTagLabelsDisabled) && (
+            {isHovered && !isTagLabelsDisabled && (
               <span className="material-symbols-outlined p-0">edit</span>
             )}
           </button>

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

@@ -1,30 +1,28 @@
 import React from 'react';
-
 import SimpleBar from 'simplebar-react';
 
 import { useSetSearchKeyword } from '~/states/search';
 
 type RenderTagLabelsProps = {
-  tags: string[],
-}
+  tags: string[];
+};
 
 const RenderTagLabels = React.memo((props: RenderTagLabelsProps) => {
   const { tags } = props;
 
   const setSearchKeyword = useSetSearchKeyword();
 
-
   return (
     <SimpleBar className="grw-tag-simple-bar pe-1">
-      {tags.map(tag => (
-        <a
+      {tags.map((tag) => (
+        <button
           key={tag}
           type="button"
           className="grw-tag badge me-1 mb-1 text-truncate mw-100"
           onClick={() => setSearchKeyword(`tag:${tag}`)}
         >
           {tag}
-        </a>
+        </button>
       ))}
     </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 {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
+import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 import { useUpdateStateAfterSave } from '~/client/services/page-operation';
 import { apiPost } from '~/client/util/apiv1-client';
 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 { TagsInput } from './TagsInput';
 
 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 { t } = useTranslation();
 
@@ -38,13 +40,16 @@ const TagEditModalSubstance: React.FC<TagEditModalSubstanceProps> = (props: TagE
   }, [initTags]);
 
   // 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 {
       await apiPost('/tags.update', updateTagsData);
       mutateTags();
@@ -52,8 +57,7 @@ const TagEditModalSubstance: React.FC<TagEditModalSubstanceProps> = (props: TagE
 
       toastSuccess('updated tags successfully');
       closeTagEditModal();
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
     }
   }, [updateTagsData, mutateTags, updateStateAfterSave, closeTagEditModal]);
@@ -64,7 +68,12 @@ const TagEditModalSubstance: React.FC<TagEditModalSubstanceProps> = (props: TagE
   }, []);
 
   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}>
         {t('tag_edit_modal.edit_tags')}
       </ModalHeader>
@@ -72,13 +81,17 @@ const TagEditModalSubstance: React.FC<TagEditModalSubstanceProps> = (props: TagE
         <TagsInput tags={tags} onTagsUpdated={handleTagsUpdate} autoFocus />
       </ModalBody>
       <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')}
         </button>
       </ModalFooter>
     </Modal>
   );
-
 };
 
 export const TagEditModal: React.FC = () => {
@@ -89,5 +102,10 @@ export const TagEditModal: React.FC = () => {
     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 React, { useRef, useState, useCallback } from 'react';
-
+import React, { useCallback, useRef, useState } from 'react';
 import { useTranslation } from 'next-i18next';
 import type { TypeaheadRef } 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';
 
 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) => {
   const { t } = useTranslation();
@@ -27,16 +26,22 @@ export const TagsInput: FC<Props> = (props: Props) => {
 
   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>) => {
     if (event.code === 'Space') {
@@ -76,7 +81,12 @@ export const TagsInput: FC<Props> = (props: Props) => {
         // option is tag name
         renderToken={(option: string, { onRemove }, idx) => {
           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}
             </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>(
     'tag-edit-modal',
-    () => import('./TagEditModal').then(mod => ({ default: mod.TagEditModal })),
+    () =>
+      import('./TagEditModal').then((mod) => ({ default: mod.TagEditModal })),
     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 {
-  DrawioViewer,
   type DrawioEditByViewerProps,
+  DrawioViewer,
   type DrawioViewerProps,
 } from '@growi/remark-drawio';
 import { useTranslation } from 'next-i18next';
 
 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 { useIsRevisionOutdated } from '~/stores/page';
 
 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';

+ 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 type { Element } from 'hast';
-import { useRouter } from 'next/router';
 
+import { useStartEditing } from '~/client/services/use-start-editing';
 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 type { ReservedNextCaretLineEventDetail } from '~/states/ui/editor/reserved-next-caret-line';
 import loggerFactory from '~/utils/logger';
 
-
 import styles from './Header.module.scss';
 
-
 const logger = loggerFactory('growi:components:Header');
 const moduleClass = styles['revision-head'] ?? '';
 
-
 function setCaretLine(lineNumber?: number): void {
   if (lineNumber != null) {
-    globalEventTarget.dispatchEvent(new CustomEvent<ReservedNextCaretLineEventDetail>('reservedNextCaretLine', {
-      detail: {
-        lineNumber,
-      },
-    }));
+    globalEventTarget.dispatchEvent(
+      new CustomEvent<ReservedNextCaretLineEventDetail>(
+        'reservedNextCaretLine',
+        {
+          detail: {
+            lineNumber,
+          },
+        },
+      ),
+    );
   }
 }
 
 type EditLinkProps = {
-  line?: number,
-}
+  line?: number;
+};
 
 /**
  * Inner FC to display edit link icon
  */
 const EditLink = (props: EditLinkProps): JSX.Element => {
   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 (
     <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>
-      </a>
+      </button>
     </span>
   );
 };
 
-
 type HeaderProps = {
-  children: React.ReactNode,
-  node: Element,
-  id?: string,
-}
+  children: React.ReactNode;
+  node: Element;
+  id?: string;
+};
 
 export const Header = (props: HeaderProps): JSX.Element => {
-  const {
-    node, id, children,
-  } = props;
+  const { node, id, children } = props;
 
   const isGuestUser = useIsGuestUser();
   const isReadOnlyUser = useIsReadOnlyUser();
@@ -75,16 +92,18 @@ export const Header = (props: HeaderProps): JSX.Element => {
 
   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
   useEffect(() => {
@@ -111,27 +130,36 @@ export const Header = (props: HeaderProps): JSX.Element => {
     return () => {
       window.removeEventListener('hashchange', activeByHashWrapper);
     };
-  }, [activateByHash, router.events]);
+  }, [activateByHash]);
 
   // 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.
   // It will be possible to address this TODO ySyncAnnotation become available for import.
   // 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 (
     <>
-      <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>
 
         {children}
 
-        { showEditButton && (
-          <EditLink line={node.position?.start.line} />
-        ) }
+        {showEditButton && <EditLink line={node.position?.start.line} />}
       </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 React, { useMemo, useState } from 'react';
-
+import { useMemo, useState } from 'react';
 import FsLightbox from 'fslightbox-react';
 import { createPortal } from 'react-dom';
 
-type Props = DetailedHTMLProps<ImgHTMLAttributes<HTMLImageElement>, HTMLImageElement>
+type Props = DetailedHTMLProps<
+  ImgHTMLAttributes<HTMLImageElement>,
+  HTMLImageElement
+>;
 
 export const LightBox = (props: Props): JSX.Element => {
   const [toggler, setToggler] = useState(false);
@@ -26,7 +29,14 @@ export const LightBox = (props: Props): JSX.Element => {
   return (
     <>
       {/* 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}
     </>

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

@@ -1,21 +1,24 @@
 import React, { useCallback } from 'react';
-
+import Image from 'next/image';
 import { UserPicture } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
-import Image from 'next/image';
 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 { useSWRxAttachment } from '~/stores/attachment';
 
 import styles from './RichAttachment.module.scss';
 
 type RichAttachmentProps = {
-  attachmentId: string,
-  url: string,
-  attachmentName: string,
-}
+  attachmentId: string;
+  url: string;
+  attachmentName: string;
+};
 
 export const RichAttachment = React.memo((props: RichAttachmentProps) => {
   const { attachmentId, attachmentName } = props;
@@ -27,7 +30,8 @@ export const RichAttachment = React.memo((props: RichAttachmentProps) => {
   const isSharedUser = useIsSharedUser();
   const isReadOnlyUser = useIsReadOnlyUser();
 
-  const showTrashButton = isGuestUser === false && isSharedUser === false && isReadOnlyUser === false;
+  const showTrashButton =
+    isGuestUser === false && isSharedUser === false && isReadOnlyUser === false;
 
   const onClickTrashButtonHandler = useCallback(() => {
     if (attachment == null) {
@@ -37,7 +41,11 @@ export const RichAttachment = React.memo((props: RichAttachmentProps) => {
   }, [attachment, openDeleteAttachmentModal, remove]);
 
   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 {
@@ -50,18 +58,26 @@ export const RichAttachment = React.memo((props: RichAttachmentProps) => {
   } = attachment;
 
   // 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 (
-    <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="p-1 card-body d-flex align-items-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}>
                 {attachmentName || originalName}
               </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>
 
               {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>
-                </a>
+                </button>
               )}
-
             </div>
             <div className="d-flex align-items-center">
               <UserPicture user={creator} size="sm" />
               <span className="ms-2 text-muted">
                 {new Date(createdAt).toLocaleString('en-US')}
               </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>

+ 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 type { Element } from 'hast';
 
 import type { LaunchHandsonTableModalEventDetail } from '~/client/interfaces/handsontable-modal';
 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 { useIsRevisionOutdated } from '~/stores/page';
 
 import styles from './TableWithEditButton.module.scss';
 
-
 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 isGuestUser = useIsGuestUser();
@@ -32,34 +36,48 @@ const TableWithEditButtonNoMemorized = (props: TableWithEditButtonProps): JSX.El
   const eol = node.position?.end.line ?? 0;
 
   const editButtonClickHandler = useCallback(() => {
-    globalEventTarget.dispatchEvent(new CustomEvent<LaunchHandsonTableModalEventDetail>('launchHandsonTableModal', {
-      detail: {
-        bol,
-        eol,
-      },
-    }));
+    globalEventTarget.dispatchEvent(
+      new CustomEvent<LaunchHandsonTableModalEventDetail>(
+        'launchHandsonTableModal',
+        {
+          detail: {
+            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 (
-    <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>
         </button>
       )}
-      <table className={className}>
-        {children}
-      </table>
+      <table className={className}>{children}</table>
     </div>
   );
 };
 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 { IPageForTreeItem } from '~/interfaces/page';
@@ -14,7 +14,15 @@ export const useScrollToSelectedItem = ({
   items,
   virtualizer,
 }: UseScrollToSelectedItemParams): void => {
+  // Track the previous targetPathOrId to detect actual changes
+  const prevTargetPathOrIdRef = useRef<string | undefined>(undefined);
+
   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;
 
     const selectedIndex = items.findIndex((item) => {

+ 1 - 20
biome.json

@@ -37,26 +37,7 @@
       "!apps/app/src/client/components/Admin/ExportArchiveData",
       "!apps/app/src/client/components/Admin/ImportData",
       "!apps/app/src/client/components/Admin/LegacySlackIntegration",
-      "!apps/app/src/client/components/Admin/MarkdownSetting",
-      "!apps/app/src/client/components/Bookmarks",
-      "!apps/app/src/client/components/DescendantsPageListModal",
-      "!apps/app/src/client/components/InAppNotification",
-      "!apps/app/src/client/components/ItemsTree",
-      "!apps/app/src/client/components/LoginForm",
-      "!apps/app/src/client/components/Me",
-      "!apps/app/src/client/components/Page",
-      "!apps/app/src/client/components/PageAttachment",
-      "!apps/app/src/client/components/PageDeleteModal",
-      "!apps/app/src/client/components/PageDuplicateModal",
-      "!apps/app/src/client/components/PageList",
-      "!apps/app/src/client/components/PageManagement",
-      "!apps/app/src/client/components/PagePathNavSticky",
-      "!apps/app/src/client/components/PagePresentationModal",
-      "!apps/app/src/client/components/PageRenameModal",
-      "!apps/app/src/client/components/PageSelectModal",
-      "!apps/app/src/client/components/PageSideContents",
-      "!apps/app/src/client/components/PageTags",
-      "!apps/app/src/client/components/ReactMarkdownComponents"
+      "!apps/app/src/client/components/Admin/MarkdownSetting"
     ]
   },
   "formatter": {

+ 1 - 1
packages/slack/package.json

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

+ 94 - 19
pnpm-lock.yaml

@@ -596,8 +596,8 @@ importers:
         specifier: ^15.8.1
         version: 15.8.1
       qs:
-        specifier: ^6.11.1
-        version: 6.13.0
+        specifier: ^6.14.1
+        version: 6.14.1
       rate-limiter-flexible:
         specifier: ^2.3.7
         version: 2.4.2
@@ -1864,8 +1864,8 @@ importers:
         specifier: ^2.0.0
         version: 2.0.1
       qs:
-        specifier: ^6.10.2
-        version: 6.13.0
+        specifier: ^6.14.1
+        version: 6.14.1
       universal-bunyan:
         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)
@@ -2752,6 +2752,9 @@ packages:
   '@codemirror/view@6.39.7':
     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':
     resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
     engines: {node: '>=0.1.90'}
@@ -6959,6 +6962,10 @@ packages:
     resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==}
     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:
     resolution: {integrity: sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==}
 
@@ -8905,6 +8912,10 @@ packages:
     resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==}
     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:
     resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
     engines: {node: '>=4.0'}
@@ -12006,6 +12017,10 @@ packages:
   object-inspect@1.13.1:
     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:
     resolution: {integrity: sha512-ncrLw+X55z7bkl5PnUvHwFK9FcGuFYo9gtjws2XtSzL+aZ8tm830P60WJ0dSmFVaSalWieW5MD7kEdnXda9yJw==}
 
@@ -12718,8 +12733,12 @@ packages:
     resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==}
     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'}
 
   quansync@0.2.10:
@@ -13617,8 +13636,20 @@ packages:
     engines: {node: '>=6'}
     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'}
 
   sift@16.0.1:
@@ -15247,6 +15278,7 @@ packages:
   whatwg-encoding@3.1.1:
     resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
     engines: {node: '>=18'}
+    deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
 
   whatwg-mimetype@3.0.0:
     resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
@@ -17420,7 +17452,7 @@ snapshots:
     dependencies:
       '@codemirror/language': 6.12.1
       '@codemirror/state': 6.5.3
-      '@codemirror/view': 6.39.7
+      '@codemirror/view': 6.39.8
       '@lezer/highlight': 1.2.3
 
   '@codemirror/view@6.39.7':
@@ -17430,6 +17462,13 @@ snapshots:
       style-mod: 4.1.3
       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':
     optional: true
 
@@ -23059,6 +23098,11 @@ snapshots:
       get-intrinsic: 1.3.0
       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: {}
 
   callsites@3.0.0: {}
@@ -24955,6 +24999,10 @@ snapshots:
     dependencies:
       estraverse: 5.3.0
 
+  esquery@1.7.0:
+    dependencies:
+      estraverse: 5.3.0
+
   esrecurse@4.3.0:
     dependencies:
       estraverse: 5.3.0
@@ -26299,7 +26347,7 @@ snapshots:
     dependencies:
       es-errors: 1.3.0
       hasown: 2.0.2
-      side-channel: 1.0.6
+      side-channel: 1.1.0
 
   internmap@1.0.1: {}
 
@@ -28851,6 +28899,8 @@ snapshots:
 
   object-inspect@1.13.1: {}
 
+  object-inspect@1.13.4: {}
+
   object-keys@0.4.0: {}
 
   object-keys@1.1.1: {}
@@ -29628,9 +29678,13 @@ snapshots:
 
   qs@6.13.0:
     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: {}
 
@@ -30411,7 +30465,7 @@ snapshots:
       mime-types: 2.1.35
       oauth-sign: 0.9.0
       performance-now: 2.1.0
-      qs: 6.5.2
+      qs: 6.5.3
       safe-buffer: 5.2.1
       tough-cookie: 2.5.0
       tunnel-agent: 0.6.0
@@ -30824,12 +30878,33 @@ snapshots:
       minimist: 1.2.8
       shelljs: 0.8.5
 
-  side-channel@1.0.6:
+  side-channel-list@1.0.0:
     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
       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: {}
 
@@ -31186,7 +31261,7 @@ snapshots:
       has-symbols: 1.1.0
       internal-slot: 1.0.7
       regexp.prototype.flags: 1.5.2
-      side-channel: 1.0.6
+      side-channel: 1.1.0
 
   string.prototype.padend@3.0.0:
     dependencies:
@@ -31391,7 +31466,7 @@ snapshots:
       formidable: 3.5.4
       methods: 1.1.2
       mime: 2.6.0
-      qs: 6.13.0
+      qs: 6.14.1
     transitivePeerDependencies:
       - supports-color
 
@@ -32548,7 +32623,7 @@ snapshots:
       eslint-scope: 5.1.1
       eslint-visitor-keys: 1.3.0
       espree: 6.2.1
-      esquery: 1.6.0
+      esquery: 1.7.0
       lodash: 4.17.21
       semver: 6.3.1
     transitivePeerDependencies: