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

Merge pull request #8777 from weseek/imprv/rename-on-blur

imprv: Rename on blur
Yuki Takei 1 год назад
Родитель
Сommit
11c8194809

+ 25 - 10
apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx

@@ -59,23 +59,36 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
     setTargetFolder(folderId);
   }, [folderId, isOpen]);
 
+  const cancel = useCallback(() => {
+    setIsRenameAction(false);
+    setIsCreateAction(false);
+  }, []);
+
   // Rename for bookmark folder handler
-  const onPressEnterHandlerForRename = useCallback(async(folderName: string) => {
+  const rename = useCallback(async(folderName: string) => {
+    if (folderName.trim() === '') {
+      return cancel();
+    }
+
     try {
       // TODO: do not use any type
-      await updateBookmarkFolder(folderId, folderName, parent as any, childFolder);
+      await updateBookmarkFolder(folderId, folderName.trim(), parent as any, childFolder);
       bookmarkFolderTreeMutation();
       setIsRenameAction(false);
     }
     catch (err) {
       toastError(err);
     }
-  }, [bookmarkFolderTreeMutation, childFolder, folderId, parent]);
+  }, [bookmarkFolderTreeMutation, cancel, childFolder, folderId, parent]);
 
   // Create new folder / subfolder handler
-  const onPressEnterHandlerForCreate = useCallback(async(folderName: string) => {
+  const create = useCallback(async(folderName: string) => {
+    if (folderName.trim() === '') {
+      return cancel();
+    }
+
     try {
-      await addNewFolder(folderName, targetFolder);
+      await addNewFolder(folderName.trim(), targetFolder);
       setIsOpen(true);
       setIsCreateAction(false);
       bookmarkFolderTreeMutation();
@@ -83,7 +96,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
     catch (err) {
       toastError(err);
     }
-  }, [bookmarkFolderTreeMutation, targetFolder]);
+  }, [bookmarkFolderTreeMutation, cancel, targetFolder]);
 
   const onClickPlusButton = useCallback(async(e) => {
     e.stopPropagation();
@@ -245,8 +258,9 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
           </div>
           {isRenameAction ? (
             <BookmarkFolderNameInput
-              onClickOutside={() => setIsRenameAction(false)}
-              onPressEnter={onPressEnterHandlerForRename}
+              onPressEnter={rename}
+              onBlur={rename}
+              onPressEscape={cancel}
               value={name}
             />
           ) : (
@@ -290,8 +304,9 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
       {isCreateAction && (
         <div className="flex-fill">
           <BookmarkFolderNameInput
-            onClickOutside={() => setIsCreateAction(false)}
-            onPressEnter={onPressEnterHandlerForCreate}
+            onPressEnter={create}
+            onBlur={create}
+            onPressEscape={cancel}
           />
         </div>
       )}

+ 3 - 11
apps/app/src/components/Bookmarks/BookmarkFolderNameInput.tsx

@@ -1,29 +1,21 @@
 import { useTranslation } from 'next-i18next';
 
 import { ValidationTarget } from '~/client/util/input-validator';
+import type { ClosableTextInputProps } from '~/components/Common/ClosableTextInput';
 import ClosableTextInput from '~/components/Common/ClosableTextInput';
 
 
-type Props = {
-  onClickOutside: () => void
-  onPressEnter: (folderName: string) => void
-  value?: string
-}
+type Props = ClosableTextInputProps;
 
 export const BookmarkFolderNameInput = (props: Props): JSX.Element => {
-  const {
-    onClickOutside, onPressEnter, value,
-  } = props;
   const { t } = useTranslation();
 
   return (
     <div className="flex-fill folder-name-input">
       <ClosableTextInput
-        value={value}
         placeholder={t('bookmark_folder.input_placeholder')}
-        onClickOutside={onClickOutside}
-        onPressEnter={onPressEnter}
         validationTarget={ValidationTarget.FOLDER}
+        {...props}
       />
     </div>
   );

+ 14 - 5
apps/app/src/components/Bookmarks/BookmarkItem.tsx

@@ -86,9 +86,17 @@ export const BookmarkItem = (props: Props): JSX.Element => {
     setRenameInputShown(true);
   }, []);
 
-  const pressEnterForRenameHandler = useCallback(async(inputText: string) => {
+  const cancel = useCallback(() => {
+    setRenameInputShown(false);
+  }, []);
+
+  const rename = useCallback(async(inputText: string) => {
+    if (inputText.trim() === '') {
+      return cancel();
+    }
+
     const parentPath = pathUtils.addTrailingSlash(nodePath.dirname(bookmarkedPage.path ?? ''));
-    const newPagePath = nodePath.resolve(parentPath, inputText);
+    const newPagePath = nodePath.resolve(parentPath, inputText.trim());
     if (newPagePath === bookmarkedPage.path) {
       setRenameInputShown(false);
       return;
@@ -104,7 +112,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
       setRenameInputShown(true);
       toastError(err);
     }
-  }, [bookmarkedPage.path, bookmarkedPage._id, bookmarkedPage.revision, bookmarkFolderTreeMutation, mutatePageInfo]);
+  }, [bookmarkedPage.path, bookmarkedPage._id, bookmarkedPage.revision, cancel, bookmarkFolderTreeMutation, mutatePageInfo]);
 
   const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoAll | undefined): Promise<void> => {
     if (bookmarkedPage._id == null || bookmarkedPage.path == null) {
@@ -158,8 +166,9 @@ export const BookmarkItem = (props: Props): JSX.Element => {
             <ClosableTextInput
               value={nodePath.basename(bookmarkedPage.path ?? '')}
               placeholder={t('Input page name')}
-              onClickOutside={() => { setRenameInputShown(false) }}
-              onPressEnter={pressEnterForRenameHandler}
+              onPressEnter={rename}
+              onBlur={rename}
+              onPressEscape={() => { setRenameInputShown(false) }}
               validationTarget={ValidationTarget.PAGE}
             />
           )

+ 28 - 33
apps/app/src/components/Common/ClosableTextInput.tsx

@@ -1,6 +1,6 @@
 import type { FC } from 'react';
 import React, {
-  memo, useEffect, useRef, useState,
+  memo, useCallback, useEffect, useRef, useState,
 } from 'react';
 
 import { useTranslation } from 'next-i18next';
@@ -9,90 +9,85 @@ import AutosizeInput from 'react-input-autosize';
 import type { AlertInfo } from '~/client/util/input-validator';
 import { AlertType, inputValidator } from '~/client/util/input-validator';
 
-type ClosableTextInputProps = {
+export type ClosableTextInputProps = {
   value?: string
   placeholder?: string
   validationTarget?: string,
   useAutosizeInput?: boolean
   inputClassName?: string,
-  onPressEnter?(inputText: string | null): void
-  onPressEscape?: () => void
-  onClickOutside?(): void
+  onPressEnter?(inputText: string): void
+  onPressEscape?(inputText: string): void
+  onBlur?(inputText: string): void
   onChange?(inputText: string): void
 }
 
 const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextInputProps) => {
   const { t } = useTranslation();
-  const { validationTarget } = props;
+  const {
+    validationTarget, onPressEnter, onPressEscape, onBlur, onChange,
+  } = props;
 
   const inputRef = useRef<HTMLInputElement>(null);
-  const [inputText, setInputText] = useState(props.value);
+  const [inputText, setInputText] = useState(props.value ?? '');
   const [currentAlertInfo, setAlertInfo] = useState<AlertInfo | null>(null);
   const [isAbleToShowAlert, setIsAbleToShowAlert] = useState(false);
   const [isComposing, setComposing] = useState(false);
 
 
-  const createValidation = async(inputText: string) => {
+  const createValidation = useCallback(async(inputText: string) => {
     const alertInfo = await inputValidator(inputText, validationTarget);
     if (alertInfo && alertInfo.message != null && alertInfo.target != null) {
       alertInfo.message = t(alertInfo.message, { target: t(alertInfo.target) });
     }
     setAlertInfo(alertInfo);
-  };
+  }, [t, validationTarget]);
 
-  const onChangeHandler = async(e: React.ChangeEvent<HTMLInputElement>) => {
+  const changeHandler = useCallback(async(e: React.ChangeEvent<HTMLInputElement>) => {
     const inputText = e.target.value;
     createValidation(inputText);
     setInputText(inputText);
     setIsAbleToShowAlert(true);
 
-    props.onChange?.(inputText);
-  };
+    onChange?.(inputText);
+  }, [createValidation, onChange]);
 
-  const onFocusHandler = async(e: React.ChangeEvent<HTMLInputElement>) => {
+  const onFocusHandler = useCallback(async(e: React.ChangeEvent<HTMLInputElement>) => {
     const inputText = e.target.value;
     await createValidation(inputText);
-  };
+  }, [createValidation]);
 
-  const onPressEnter = () => {
-    if (props.onPressEnter != null) {
-      const text = inputText != null ? inputText.trim() : null;
-      if (currentAlertInfo == null) {
-        props.onPressEnter(text);
-      }
+  const pressEnterHandler = useCallback(() => {
+    if (currentAlertInfo == null) {
+      onPressEnter?.(inputText.trim());
     }
-  };
+  }, [currentAlertInfo, inputText, onPressEnter]);
 
-  const onKeyDownHandler = (e) => {
+  const onKeyDownHandler = useCallback((e) => {
     switch (e.key) {
       case 'Enter':
         // Do nothing when composing
         if (isComposing) {
           return;
         }
-        onPressEnter();
+        pressEnterHandler();
         break;
       case 'Escape':
         if (isComposing) {
           return;
         }
-        props.onPressEscape?.();
+        onPressEscape?.(inputText.trim());
         break;
       default:
         break;
     }
-  };
+  }, [inputText, isComposing, pressEnterHandler, onPressEscape]);
 
   /*
    * Hide when click outside the ref
    */
-  const onBlurHandler = () => {
-    if (props.onClickOutside == null) {
-      return;
-    }
-
-    props.onClickOutside();
-  };
+  const onBlurHandler = useCallback(() => {
+    onBlur?.(inputText.trim());
+  }, [inputText, onBlur]);
 
   // didMount
   useEffect(() => {
@@ -126,7 +121,7 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
     placeholder: props.placeholder,
     name: 'input',
     onFocus: onFocusHandler,
-    onChange: onChangeHandler,
+    onChange: changeHandler,
     onKeyDown: onKeyDownHandler,
     onCompositionStart: () => setComposing(true),
     onCompositionEnd: () => setComposing(false),

+ 18 - 27
apps/app/src/components/PageHeader/PagePathHeader.tsx

@@ -41,7 +41,6 @@ export const PagePathHeader = memo((props: Props): JSX.Element => {
 
   const [isRenameInputShown, setRenameInputShown] = useState(false);
   const [isHover, setHover] = useState(false);
-  const [editingParentPagePath, setEditingParentPagePath] = useState(parentPagePath);
 
   // const [isIconHidden, setIsIconHidden] = useState(false);
 
@@ -50,35 +49,28 @@ export const PagePathHeader = memo((props: Props): JSX.Element => {
 
   const pagePathRenameHandler = usePagePathRenameHandler(currentPage);
 
-  const onRenameFinish = useCallback(() => {
-    setRenameInputShown(false);
-    onRenameTerminated?.();
-  }, [onRenameTerminated]);
-
-  const onRenameFailure = useCallback(() => {
-    setRenameInputShown(true);
-  }, []);
 
-  const onInputChange = useCallback((inputText: string) => {
-    setEditingParentPagePath(inputText);
-  }, []);
-
-  const onPressEnter = useCallback(() => {
-    const pathToRename = normalizePath(`${editingParentPagePath}/${dPagePath.latter}`);
-    pagePathRenameHandler(pathToRename, onRenameFinish, onRenameFailure);
-  }, [editingParentPagePath, onRenameFailure, onRenameFinish, pagePathRenameHandler, dPagePath.latter]);
+  const rename = useCallback((inputText) => {
+    const pathToRename = normalizePath(`${inputText}/${dPagePath.latter}`);
+    pagePathRenameHandler(pathToRename,
+      () => {
+        setRenameInputShown(false);
+        onRenameTerminated?.();
+      },
+      () => {
+        setRenameInputShown(true);
+      });
+  }, [dPagePath.latter, pagePathRenameHandler, onRenameTerminated]);
 
-  const onPressEscape = useCallback(() => {
+  const cancel = useCallback(() => {
     // reset
-    setEditingParentPagePath(parentPagePath);
     setRenameInputShown(false);
-  }, [parentPagePath]);
+  }, []);
 
   const onClickEditButton = useCallback(() => {
     // reset
-    setEditingParentPagePath(parentPagePath);
     setRenameInputShown(true);
-  }, [parentPagePath]);
+  }, []);
 
   // TODO: https://redmine.weseek.co.jp/issues/141062
   // Truncate left side and don't use getElementById
@@ -119,14 +111,13 @@ export const PagePathHeader = memo((props: Props): JSX.Element => {
           <div className="position-relative">
             <div className="position-absolute w-100">
               <ClosableTextInput
-                value={editingParentPagePath}
+                value={parentPagePath}
                 placeholder={t('Input parent page path')}
                 inputClassName="form-control-sm"
-                onPressEnter={onPressEnter}
-                onPressEscape={onPressEscape}
-                onChange={onInputChange}
+                onPressEnter={rename}
+                onPressEscape={cancel}
+                onBlur={rename}
                 validationTarget={ValidationTarget.PAGE}
-                onClickOutside={onPressEscape}
                 useAutosizeInput
               />
             </div>

+ 17 - 19
apps/app/src/components/PageHeader/PageTitleHeader.tsx

@@ -51,16 +51,7 @@ export const PageTitleHeader = (props: Props): JSX.Element => {
 
   const isNewlyCreatedPage = (currentPage.wip && currentPage.latestRevision == null && untitledPageRegex.test(editedPageTitle)) ?? false;
 
-  const onRenameFinish = useCallback(() => {
-    setRenameInputShown(false);
-    onMoveTerminated?.();
-  }, [onMoveTerminated]);
-
-  const onRenameFailure = useCallback(() => {
-    setRenameInputShown(true);
-  }, []);
-
-  const onInputChange = useCallback((inputText: string) => {
+  const inputChangeHandler = useCallback((inputText: string) => {
     const newPageTitle = pathUtils.removeHeadingSlash(inputText);
     const parentPagePath = pathUtils.addTrailingSlash(nodePath.dirname(currentPage.path));
     const newPagePath = nodePath.resolve(parentPagePath, newPageTitle);
@@ -68,11 +59,18 @@ export const PageTitleHeader = (props: Props): JSX.Element => {
     setEditedPagePath(newPagePath);
   }, [currentPage?.path, setEditedPagePath]);
 
-  const onPressEnter = useCallback(() => {
-    pagePathRenameHandler(editedPagePath, onRenameFinish, onRenameFailure);
-  }, [editedPagePath, onRenameFailure, onRenameFinish, pagePathRenameHandler]);
-
-  const onPressEscape = useCallback(() => {
+  const rename = useCallback(() => {
+    pagePathRenameHandler(editedPagePath,
+      () => {
+        setRenameInputShown(false);
+        onMoveTerminated?.();
+      },
+      () => {
+        setRenameInputShown(true);
+      });
+  }, [editedPagePath, onMoveTerminated, pagePathRenameHandler]);
+
+  const cancel = useCallback(() => {
     setEditedPagePath(currentPagePath);
     setRenameInputShown(false);
   }, [currentPagePath]);
@@ -104,10 +102,10 @@ export const PageTitleHeader = (props: Props): JSX.Element => {
                 value={isNewlyCreatedPage ? '' : editedPageTitle}
                 placeholder={t('Input page name')}
                 inputClassName="fs-4"
-                onPressEnter={onPressEnter}
-                onPressEscape={onPressEscape}
-                onChange={onInputChange}
-                onClickOutside={() => { setRenameInputShown(false) }}
+                onPressEnter={rename}
+                onPressEscape={cancel}
+                onChange={inputChangeHandler}
+                onBlur={rename}
                 validationTarget={ValidationTarget.PAGE}
                 useAutosizeInput
               />

+ 12 - 7
apps/app/src/components/Sidebar/Bookmarks/BookmarkContents.tsx

@@ -2,7 +2,7 @@ import React, { useCallback, useState } from 'react';
 
 import { useTranslation } from 'next-i18next';
 
-import { apiv3Post } from '~/client/util/apiv3-client';
+import { addNewFolder } from '~/client/util/bookmark-utils';
 import { toastError } from '~/client/util/toastr';
 import { BookmarkFolderNameInput } from '~/components/Bookmarks/BookmarkFolderNameInput';
 import { BookmarkFolderTree } from '~/components/Bookmarks/BookmarkFolderTree';
@@ -21,20 +21,24 @@ export const BookmarkContents = (): JSX.Element => {
     setIsCreateAction(true);
   }, []);
 
-  const onClickonClickOutsideHandler = useCallback(() => {
+  const cancel = useCallback(() => {
     setIsCreateAction(false);
   }, []);
 
-  const onPressEnterHandlerForCreate = useCallback(async(folderName: string) => {
+  const create = useCallback(async(folderName: string) => {
+    if (folderName.trim() === '') {
+      return cancel();
+    }
+
     try {
-      await apiv3Post('/bookmark-folder', { name: folderName, parent: null });
+      await addNewFolder(folderName.trim(), null);
       await mutateBookmarkFolders();
       setIsCreateAction(false);
     }
     catch (err) {
       toastError(err);
     }
-  }, [mutateBookmarkFolders]);
+  }, [cancel, mutateBookmarkFolders]);
 
   return (
     <div>
@@ -54,8 +58,9 @@ export const BookmarkContents = (): JSX.Element => {
       {isCreateAction && (
         <div className="col-12 mb-2 ">
           <BookmarkFolderNameInput
-            onClickOutside={onClickonClickOutsideHandler}
-            onPressEnter={onPressEnterHandlerForCreate}
+            onPressEnter={create}
+            onBlur={create}
+            onPressEscape={cancel}
           />
         </div>
       )}

+ 14 - 7
apps/app/src/components/Sidebar/PageTreeItem/Ellipsis.tsx

@@ -66,7 +66,15 @@ export const Ellipsis: FC<TreeItemToolProps> = (props) => {
     setRenameInputShown(true);
   }, []);
 
-  const onPressEnterForRenameHandler = async(inputText: string) => {
+  const cancel = useCallback(() => {
+    setRenameInputShown(false);
+  }, []);
+
+  const rename = useCallback(async(inputText) => {
+    if (inputText.trim() === '') {
+      return cancel();
+    }
+
     const parentPath = pathUtils.addTrailingSlash(nodePath.dirname(page.path ?? ''));
     const newPagePath = nodePath.resolve(parentPath, inputText);
 
@@ -83,9 +91,7 @@ export const Ellipsis: FC<TreeItemToolProps> = (props) => {
         newPagePath,
       });
 
-      if (onRenamed != null) {
-        onRenamed(page.path, newPagePath);
-      }
+      onRenamed?.(page.path, newPagePath);
 
       toastSuccess(t('renamed_pages', { path: page.path }));
     }
@@ -93,7 +99,7 @@ export const Ellipsis: FC<TreeItemToolProps> = (props) => {
       setRenameInputShown(true);
       toastError(err);
     }
-  };
+  }, [cancel, onRenamed, page._id, page.path, page.revision, t]);
 
   const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoAll | undefined): Promise<void> => {
     if (onClickDeleteMenuItem == null) {
@@ -136,8 +142,9 @@ export const Ellipsis: FC<TreeItemToolProps> = (props) => {
             <ClosableTextInput
               value={nodePath.basename(page.path ?? '')}
               placeholder={t('Input page name')}
-              onClickOutside={() => { setRenameInputShown(false) }}
-              onPressEnter={onPressEnterForRenameHandler}
+              onPressEnter={rename}
+              onBlur={rename}
+              onPressEscape={cancel}
               validationTarget={ValidationTarget.PAGE}
             />
           </NotDraggableForClosableTextInput>

+ 16 - 18
apps/app/src/components/TreeItem/NewPageInput/NewPageInput.tsx

@@ -1,4 +1,6 @@
-import React, { type FC, useCallback, useEffect } from 'react';
+import React, {
+  type FC, useCallback,
+} from 'react';
 
 import nodePath from 'path';
 
@@ -29,7 +31,15 @@ export const NewPageInput: FC<Props> = (props) => {
     onCanceled,
   } = props;
 
-  const onPressEnterForCreateHandler = async(inputText: string) => {
+  const cancel = useCallback(() => {
+    onCanceled?.();
+  }, [onCanceled]);
+
+  const create = useCallback(async(inputText) => {
+    if (inputText.trim() === '') {
+      return cancel();
+    }
+
     const parentPath = pathUtils.addTrailingSlash(page.path as string);
     const newPagePath = nodePath.resolve(parentPath, inputText);
     const isCreatable = pagePathUtils.isCreatablePage(newPagePath);
@@ -49,20 +59,7 @@ export const NewPageInput: FC<Props> = (props) => {
     finally {
       onSubmittionFailed?.();
     }
-  };
-
-  const onPressEscHandler = useCallback((event) => {
-    if (event.keyCode === 27) {
-      onCanceled?.();
-    }
-  }, [onCanceled]);
-
-  useEffect(() => {
-    document.addEventListener('keydown', onPressEscHandler, false);
-    return () => {
-      document.removeEventListener('keydown', onPressEscHandler, false);
-    };
-  }, [onPressEscHandler]);
+  }, [cancel, onSubmit, onSubmittionFailed, page.path, t]);
 
   return (
     <>
@@ -70,8 +67,9 @@ export const NewPageInput: FC<Props> = (props) => {
         <NotDraggableForClosableTextInput>
           <ClosableTextInput
             placeholder={t('Input page name')}
-            onClickOutside={onCanceled}
-            onPressEnter={onPressEnterForCreateHandler}
+            onPressEnter={create}
+            onPressEscape={cancel}
+            onBlur={create}
             validationTarget={ValidationTarget.PAGE}
           />
         </NotDraggableForClosableTextInput>