Browse Source

WIP: re-implement validation

Yuki Takei 1 year ago
parent
commit
822afc4e6a

+ 14 - 10
apps/app/public/static/locales/en_US/translation.json

@@ -160,16 +160,20 @@
   "not_allowed_to_see_this_page": "You cannot see this page",
   "Confirm": "Confirm",
   "Successfully requested": "Successfully requested.",
-  "form_validation": {
-    "error_message": "Some values ​​are incorrect",
-    "required": "%s is required",
-    "invalid_syntax": "The syntax of %s is invalid.",
-    "title_required": "Title is required.",
-    "field_required": "{{target}} is required"
-  },
-  "page_name": "Page name",
-  "folder_name": "Folder name",
-  "field": "field",
+  "input_validation": {
+    "target": {
+      "page_name": "Page name",
+      "folder_name": "Folder name",
+      "field": "field"
+    },
+    "message": {
+      "error_message": "Some values ​​are incorrect",
+      "required": "%s is required",
+      "invalid_syntax": "The syntax of %s is invalid.",
+      "title_required": "Title is required.",
+      "field_required": "{{target}} is required"
+    }
+  },
   "not_creatable_page": {
     "message": "Page contents cannot be created in this path."
   },

+ 14 - 10
apps/app/public/static/locales/fr_FR/translation.json

@@ -160,16 +160,20 @@
   "not_allowed_to_see_this_page": "Vous ne pouvez pas voir cette page",
   "Confirm": "Confirmer",
   "Successfully requested": "Demande envoyée.",
-  "form_validation": {
-    "error_message": "Des champs sont invalides",
-    "required": "%s est requis",
-    "invalid_syntax": "La syntaxe de %s est invalide.",
-    "title_required": "Titre requis.",
-    "field_required": "{{target}} est requis"
-  },
-  "page_name": "Nom de la page",
-  "folder_name": "Nom du dossier",
-  "field": "champ",
+  "input_validation": {
+    "target": {
+      "page_name": "Nom de la page",
+      "folder_name": "Nom du dossier",
+      "field": "champ"
+    },
+    "message": {
+      "error_message": "Des champs sont invalides",
+      "required": "%s est requis",
+      "invalid_syntax": "La syntaxe de %s est invalide.",
+      "title_required": "Titre requis.",
+      "field_required": "{{target}} est requis"
+    }
+  },
   "not_creatable_page": {
     "message": "Vous ne pouvez pas créer cette page dans ce chemin."
   },

+ 14 - 10
apps/app/public/static/locales/ja_JP/translation.json

@@ -161,16 +161,20 @@
   "not_allowed_to_see_this_page": "このページは閲覧できません",
   "Confirm": "確認",
   "Successfully requested": "正常に処理を受け付けました",
-  "form_validation": {
-    "error_message": "いくつかの値が設定されていません",
-    "required": "%sに値を入力してください",
-    "invalid_syntax": "%sの構文が不正です",
-    "title_required": "タイトルを入力してください",
-    "field_required": "{{target}}に値を入力してください"
-  },
-  "page_name": "ページ名",
-  "folder_name": "フォルダ名",
-  "field": "フィールド",
+  "input_validation": {
+    "target": {
+      "page_name": "ページ名",
+      "folder_name": "フォルダ名",
+      "field": "フィールド"
+    },
+    "message": {
+      "error_message": "いくつかの値が設定されていません",
+      "required": "%sに値を入力してください",
+      "invalid_syntax": "%sの構文が不正です",
+      "title_required": "タイトルを入力してください",
+      "field_required": "{{target}}に値を入力してください"
+    }
+  },
   "not_creatable_page": {
     "message": "このパスではページ コンテンツを作成できません。"
   },

+ 14 - 10
apps/app/public/static/locales/zh_CN/translation.json

@@ -167,16 +167,20 @@
   "Confirm": "确定",
   "Successfully requested": "进程成功接受",
   "copied_to_clipboard": "它已复制到剪贴板。",
-  "form_validation": {
-    "error_message": "有些值不正确",
-    "required": "%s 是必需的",
-    "invalid_syntax": "%s的语法无效。",
-    "title_required": "标题是必需的。",
-    "field_required": "{{target}} 是必需的"
-  },
-  "page_name": "页面名称",
-  "folder_name": "文件夹名称",
-  "field": "字段",
+  "input_validation": {
+    "target": {
+      "page_name": "页面名称",
+      "folder_name": "文件夹名称",
+      "field": "字段"
+    },
+    "message": {
+      "error_message": "有些值不正确",
+      "required": "%s 是必需的",
+      "invalid_syntax": "%s的语法无效。",
+      "title_required": "标题是必需的。",
+      "field_required": "{{target}} 是必需的"
+    }
+  },
   "not_creatable_page": {
     "message": "无法在此路径中创建页面内容。"
   },

+ 0 - 32
apps/app/src/client/util/input-validator.ts

@@ -1,32 +0,0 @@
-export const AlertType = {
-  WARNING: 'warning',
-  ERROR: 'error',
-} as const;
-
-export type AlertType = typeof AlertType[keyof typeof AlertType];
-
-export const ValidationTarget = {
-  FOLDER: 'folder_name',
-  PAGE: 'page_name',
-  DEFAULT: 'field',
-};
-
-export type ValidationTarget = typeof ValidationTarget[keyof typeof ValidationTarget];
-
-export type AlertInfo = {
-  type?: AlertType
-  message?: string,
-  target?: string
-}
-
-export const inputValidator = async(title: string | null, target?: string): Promise<AlertInfo | null> => {
-  const validationTarget = target || ValidationTarget.DEFAULT;
-  if (title == null || title === '' || title.trim() === '') {
-    return {
-      type: AlertType.WARNING,
-      message: 'form_validation.field_required',
-      target: validationTarget,
-    };
-  }
-  return null;
-};

+ 56 - 0
apps/app/src/client/util/use-input-validator.ts

@@ -0,0 +1,56 @@
+import { useCallback } from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+export const AlertType = {
+  WARNING: 'Warning',
+  ERROR: 'Error',
+} as const;
+
+export type AlertType = typeof AlertType[keyof typeof AlertType];
+
+export const ValidationTarget = {
+  FOLDER: 'folder_name',
+  PAGE: 'page_name',
+  DEFAULT: 'field',
+};
+
+export type ValidationTarget = typeof ValidationTarget[keyof typeof ValidationTarget];
+
+export type AlertInfo = {
+  type?: AlertType
+  message?: string,
+  target?: string
+}
+
+
+export type InputValidationResult = {
+  type: AlertType
+  typeLabel: string,
+  message: string,
+  target: string
+}
+
+export type InputValidator = (input?: string, alertType?: AlertType) => InputValidationResult | void;
+
+export const useInputValidator = (validationTarget: ValidationTarget = ValidationTarget.DEFAULT): InputValidator => {
+  const { t } = useTranslation();
+
+  const inputValidator: InputValidator = useCallback((input?, alertType = AlertType.WARNING) => {
+    if ((input ?? '').trim() === '') {
+      return {
+        target: validationTarget,
+        type: alertType,
+        typeLabel: t(alertType),
+        message: t(
+          'input_validation.message.field_required',
+          { target: t(`input_validation.target.${validationTarget}`) },
+        ),
+      };
+    }
+
+    return;
+  }, [t, validationTarget]);
+
+  return inputValidator;
+};

+ 1 - 1
apps/app/src/components/Bookmarks/BookmarkFolderNameInput.tsx

@@ -1,6 +1,6 @@
 import { useTranslation } from 'next-i18next';
 
-import { ValidationTarget } from '~/client/util/input-validator';
+import { ValidationTarget } from '~/client/util/use-input-validator';
 import type { ClosableTextInputProps } from '~/components/Common/ClosableTextInput';
 import ClosableTextInput from '~/components/Common/ClosableTextInput';
 

+ 1 - 1
apps/app/src/components/Bookmarks/BookmarkItem.tsx

@@ -12,7 +12,7 @@ import { UncontrolledTooltip, DropdownToggle } from 'reactstrap';
 
 import { bookmark, unbookmark, unlink } from '~/client/services/page-operation';
 import { addBookmarkToFolder, renamePage } from '~/client/util/bookmark-utils';
-import { ValidationTarget } from '~/client/util/input-validator';
+import { ValidationTarget } from '~/client/util/use-input-validator';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import type { BookmarkFolderItems, DragItemDataType } from '~/interfaces/bookmark-info';
 import { DRAG_ITEM_TYPE } from '~/interfaces/bookmark-info';

+ 2 - 2
apps/app/src/components/Common/ClosableTextInput.tsx

@@ -6,8 +6,8 @@ import React, {
 import { useTranslation } from 'next-i18next';
 import AutosizeInput from 'react-input-autosize';
 
-import type { AlertInfo } from '~/client/util/input-validator';
-import { AlertType, inputValidator } from '~/client/util/input-validator';
+import type { AlertInfo } from '~/client/util/use-input-validator';
+import { AlertType, inputValidator } from '~/client/util/use-input-validator';
 
 
 // for react-input-autosize

+ 1 - 1
apps/app/src/components/PageHeader/PagePathHeader.tsx

@@ -7,7 +7,7 @@ import { DevidedPagePath } from '@growi/core/dist/models';
 import { normalizePath } from '@growi/core/dist/utils/path-utils';
 import { useTranslation } from 'next-i18next';
 
-import { ValidationTarget } from '~/client/util/input-validator';
+import { ValidationTarget } from '~/client/util/use-input-validator';
 import LinkedPagePath from '~/models/linked-page-path';
 import { usePageSelectModal } from '~/stores/modal';
 

+ 1 - 1
apps/app/src/components/PageHeader/PageTitleHeader.tsx

@@ -8,7 +8,7 @@ import { pathUtils } from '@growi/core/dist/utils';
 import { isMovablePage } from '@growi/core/dist/utils/page-path-utils';
 import { useTranslation } from 'next-i18next';
 
-import { ValidationTarget } from '~/client/util/input-validator';
+import { ValidationTarget } from '~/client/util/use-input-validator';
 
 import ClosableTextInput from '../Common/ClosableTextInput';
 import { CopyDropdown } from '../Common/CopyDropdown';

+ 23 - 3
apps/app/src/components/Sidebar/PageTreeItem/PageTreeItem.tsx

@@ -13,6 +13,7 @@ import { useDrag, useDrop } from 'react-dnd';
 
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastWarning, toastError } from '~/client/util/toastr';
+import { AlertType } from '~/client/util/use-input-validator';
 import type { IPageForItem } from '~/interfaces/page';
 import { mutatePageTree, useSWRxPageChildren } from '~/stores/page-listing';
 import loggerFactory from '~/utils/logger';
@@ -60,9 +61,13 @@ export const PageTreeItem: FC<TreeItemProps> = (props) => {
   const [isOpen, setIsOpen] = useState(_isOpen);
   const [shouldHide, setShouldHide] = useState(false);
 
-  const { showRenameInput, Control, RenameInput } = usePageItemControl();
   const { mutate: mutateChildren } = useSWRxPageChildren(isOpen ? page._id : null);
 
+  const {
+    showRenameInput, validationResult, Control, RenameInput,
+  } = usePageItemControl();
+  const { isProcessingSubmission, Input: NewPageInput, CreateButton: NewPageCreateButton } = useNewPageInput();
+
   const itemSelectedHandler = useCallback((page: IPageForItem) => {
     if (page.path == null || page._id == null) {
       return;
@@ -178,7 +183,22 @@ export const PageTreeItem: FC<TreeItemProps> = (props) => {
 
   const mainClassName = `${isOver ? 'grw-pagetree-is-over' : ''} ${shouldHide ? 'd-none' : ''}`;
 
-  const { isProcessingSubmission, Input: NewPageInput, CreateButton: NewPageCreateButton } = useNewPageInput();
+  const ValidationResult = useCallback(() => {
+    if (validationResult == null) return <></>;
+
+    const {
+      type, typeLabel, message,
+    } = validationResult;
+
+    return (
+      <div
+        className={`mt-1 alert ${type === AlertType.ERROR ? 'text-danger' : 'text-warning'}`}
+        role="alert"
+      >
+        {typeLabel}: {message}
+      </div>
+    );
+  }, [validationResult]);
 
   return (
     <TreeItemLayout
@@ -197,7 +217,7 @@ export const PageTreeItem: FC<TreeItemProps> = (props) => {
       mainClassName={mainClassName}
       customEndComponents={[CountBadgeForPageTreeItem]}
       customHoveredEndComponents={[Control, NewPageCreateButton]}
-      customNextComponents={[NewPageInput]}
+      customNextComponents={[ValidationResult, NewPageInput]}
       customNextToChildrenComponents={[() => <CreatingNewPageSpinner show={isProcessingSubmission} />]}
       showAlternativeContent={showRenameInput}
       customAlternativeComponents={[RenameInput]}

+ 21 - 4
apps/app/src/components/Sidebar/PageTreeItem/use-page-item-control.tsx

@@ -1,4 +1,4 @@
-import type { FC } from 'react';
+import type { ChangeEvent, FC } from 'react';
 import React, {
   useCallback, useRef, useState,
 } from 'react';
@@ -10,11 +10,12 @@ import { pathUtils } from '@growi/core/dist/utils';
 import { useRect } from '@growi/ui/dist/utils';
 import { useTranslation } from 'next-i18next';
 import { DropdownToggle } from 'reactstrap';
+import { debounce } from 'throttle-debounce';
 
 import { bookmark, unbookmark, resumeRenameOperation } from '~/client/services/page-operation';
 import { apiv3Put } from '~/client/util/apiv3-client';
-// import { ValidationTarget } from '~/client/util/input-validator';
 import { toastError, toastSuccess } from '~/client/util/toastr';
+import { ValidationTarget, useInputValidator, type InputValidationResult } from '~/client/util/use-input-validator';
 import { AutosizeSubmittableInput } from '~/components/Common/SubmittableInput';
 import { NotAvailableForGuest } from '~/components/NotAvailableForGuest';
 import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
@@ -31,12 +32,14 @@ type UsePageItemControl = {
   Control: FC<TreeItemToolProps>,
   RenameInput: FC<TreeItemToolProps>,
   showRenameInput: boolean,
+  validationResult?: InputValidationResult,
 }
 
 export const usePageItemControl = (): UsePageItemControl => {
   const { t } = useTranslation();
 
   const [showRenameInput, setShowRenameInput] = useState(false);
+  const [validationResult, setValidationResult] = useState<InputValidationResult>();
 
 
   const Control: FC<TreeItemToolProps> = (props) => {
@@ -143,7 +146,16 @@ export const usePageItemControl = (): UsePageItemControl => {
     const parentRef = useRef<HTMLDivElement>(null);
     const [parentRect] = useRect(parentRef);
 
+    const inputValidator = useInputValidator(ValidationTarget.PAGE);
+
+    const changeHandler = useCallback(async(e: ChangeEvent<HTMLInputElement>) => {
+      const validationResult = inputValidator(e.target.value);
+      setValidationResult(validationResult ?? undefined);
+    }, [inputValidator]);
+    const changeHandlerDebounced = debounce(300, changeHandler);
+
     const cancel = useCallback(() => {
+      setValidationResult(undefined);
       setShowRenameInput(false);
     }, []);
 
@@ -156,6 +168,7 @@ export const usePageItemControl = (): UsePageItemControl => {
       const newPagePath = nodePath.resolve(parentPath, inputText);
 
       if (newPagePath === page.path) {
+        setValidationResult(undefined);
         setShowRenameInput(false);
         return;
       }
@@ -173,9 +186,12 @@ export const usePageItemControl = (): UsePageItemControl => {
         toastSuccess(t('renamed_pages', { path: page.path }));
       }
       catch (err) {
-        setShowRenameInput(true);
         toastError(err);
       }
+      finally {
+        setValidationResult(undefined);
+      }
+
     }, [cancel, onRenamed, page._id, page.path, page.revision]);
 
 
@@ -189,9 +205,9 @@ export const usePageItemControl = (): UsePageItemControl => {
           inputClassName="form-control"
           inputStyle={{ maxWidth }}
           placeholder={t('Input page name')}
+          onChange={changeHandlerDebounced}
           onSubmit={rename}
           onCancel={cancel}
-          // validationTarget={ValidationTarget.PAGE}
           autoFocus
         />
       </div>
@@ -203,6 +219,7 @@ export const usePageItemControl = (): UsePageItemControl => {
     Control,
     RenameInput,
     showRenameInput,
+    validationResult,
   };
 
 };

+ 1 - 1
apps/app/src/components/TreeItem/NewPageInput/NewPageInput.tsx

@@ -7,7 +7,7 @@ import nodePath from 'path';
 import { pathUtils, pagePathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
 
-import { ValidationTarget } from '~/client/util/input-validator';
+import { ValidationTarget } from '~/client/util/use-input-validator';
 import { toastWarning, toastError, toastSuccess } from '~/client/util/toastr';
 import ClosableTextInput from '~/components/Common/ClosableTextInput';
 import type { IPageForItem } from '~/interfaces/page';