use-new-page-input.tsx 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. import type { ChangeEvent } from 'react';
  2. import React, {
  3. useState, type FC, useCallback, useRef,
  4. } from 'react';
  5. import nodePath from 'path';
  6. import { Origin } from '@growi/core';
  7. import { pathUtils, pagePathUtils } from '@growi/core/dist/utils';
  8. import { useRect } from '@growi/ui/dist/utils';
  9. import { useTranslation } from 'next-i18next';
  10. import { debounce } from 'throttle-debounce';
  11. import { createPage } from '~/client/services/create-page';
  12. import { toastWarning, toastError, toastSuccess } from '~/client/util/toastr';
  13. import type { InputValidationResult } from '~/client/util/use-input-validator';
  14. import { ValidationTarget, useInputValidator } from '~/client/util/use-input-validator';
  15. import { AutosizeSubmittableInput, getAdjustedMaxWidthForAutosizeInput } from '~/components/Common/SubmittableInput';
  16. import { mutatePageTree } from '~/stores/page-listing';
  17. import { usePageTreeDescCountMap } from '~/stores/ui';
  18. import { shouldCreateWipPage } from '../../../utils/should-create-wip-page';
  19. import type { TreeItemToolProps } from '../interfaces';
  20. import { NewPageCreateButton } from './NewPageCreateButton';
  21. import newPageInputStyles from './NewPageInput.module.scss';
  22. type UseNewPageInput = {
  23. Input: FC<TreeItemToolProps>,
  24. CreateButton: FC<TreeItemToolProps>,
  25. isProcessingSubmission: boolean,
  26. }
  27. export const useNewPageInput = (): UseNewPageInput => {
  28. const [showInput, setShowInput] = useState(false);
  29. const [isProcessingSubmission, setProcessingSubmission] = useState(false);
  30. const CreateButton: FC<TreeItemToolProps> = (props) => {
  31. const { itemNode, stateHandlers } = props;
  32. const { page } = itemNode;
  33. const onClick = useCallback(() => {
  34. setShowInput(true);
  35. stateHandlers?.setIsOpen(true);
  36. }, [stateHandlers]);
  37. return (
  38. <NewPageCreateButton
  39. page={page}
  40. onClick={onClick}
  41. />
  42. );
  43. };
  44. const Input: FC<TreeItemToolProps> = (props) => {
  45. const { t } = useTranslation();
  46. const { itemNode, stateHandlers, isEnableActions } = props;
  47. const { page, children } = itemNode;
  48. const { getDescCount } = usePageTreeDescCountMap();
  49. const descendantCount = getDescCount(page._id) || page.descendantCount || 0;
  50. const isChildrenLoaded = children?.length > 0;
  51. const hasDescendants = descendantCount > 0 || isChildrenLoaded;
  52. const parentRef = useRef<HTMLDivElement>(null);
  53. const [parentRect] = useRect(parentRef);
  54. const [validationResult, setValidationResult] = useState<InputValidationResult>();
  55. const inputValidator = useInputValidator(ValidationTarget.PAGE);
  56. const changeHandler = useCallback(async(e: ChangeEvent<HTMLInputElement>) => {
  57. const validationResult = inputValidator(e.target.value);
  58. setValidationResult(validationResult ?? undefined);
  59. }, [inputValidator]);
  60. const changeHandlerDebounced = debounce(300, changeHandler);
  61. const cancel = useCallback(() => {
  62. setValidationResult(undefined);
  63. setShowInput(false);
  64. }, []);
  65. const create = useCallback(async(inputText) => {
  66. if (inputText.trim() === '') {
  67. return cancel();
  68. }
  69. const parentPath = pathUtils.addTrailingSlash(page.path as string);
  70. const newPagePath = nodePath.resolve(parentPath, inputText);
  71. const isCreatable = pagePathUtils.isCreatablePage(newPagePath);
  72. if (!isCreatable) {
  73. toastWarning(t('you_can_not_create_page_with_this_name'));
  74. return;
  75. }
  76. setProcessingSubmission(true);
  77. setShowInput(false);
  78. try {
  79. await createPage({
  80. path: newPagePath,
  81. body: undefined,
  82. // keep grant info undefined to inherit from parent
  83. grant: undefined,
  84. grantUserGroupIds: undefined,
  85. origin: Origin.View,
  86. wip: shouldCreateWipPage(newPagePath),
  87. });
  88. mutatePageTree();
  89. if (!hasDescendants) {
  90. stateHandlers?.setIsOpen(true);
  91. }
  92. toastSuccess(t('successfully_saved_the_page'));
  93. }
  94. catch (err) {
  95. toastError(err);
  96. }
  97. finally {
  98. setProcessingSubmission(false);
  99. }
  100. }, [cancel, hasDescendants, page.path, stateHandlers, t]);
  101. const inputContainerClass = newPageInputStyles['new-page-input-container'] ?? '';
  102. const isInvalid = validationResult != null;
  103. const maxWidth = parentRect != null
  104. ? getAdjustedMaxWidthForAutosizeInput(parentRect.width, 'sm', validationResult != null ? false : undefined)
  105. : undefined;
  106. return isEnableActions && showInput
  107. ? (
  108. <div ref={parentRef} className={inputContainerClass}>
  109. <AutosizeSubmittableInput
  110. inputClassName={`form-control ${isInvalid ? 'is-invalid' : ''}`}
  111. inputStyle={{ maxWidth }}
  112. placeholder={t('Input page name')}
  113. aria-describedby={isInvalid ? 'new-page-input-feedback' : undefined}
  114. onChange={changeHandlerDebounced}
  115. onSubmit={create}
  116. onCancel={cancel}
  117. autoFocus
  118. />
  119. { isInvalid && (
  120. <div id="new-page-input" className="invalid-feedback d-block my-1">
  121. {validationResult.message}
  122. </div>
  123. ) }
  124. </div>
  125. )
  126. : <></>;
  127. };
  128. return {
  129. Input,
  130. CreateButton,
  131. isProcessingSubmission,
  132. };
  133. };