PageCreateModal.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. import React, {
  2. useEffect, useState, useMemo, useCallback,
  3. } from 'react';
  4. import path from 'path';
  5. import { Origin } from '@growi/core';
  6. import { pagePathUtils, pathUtils } from '@growi/core/dist/utils';
  7. import { normalizePath } from '@growi/core/dist/utils/path-utils';
  8. import { format } from 'date-fns/format';
  9. import { useTranslation } from 'next-i18next';
  10. import {
  11. Modal, ModalHeader, ModalBody, UncontrolledButtonDropdown, DropdownToggle, DropdownMenu, DropdownItem,
  12. } from 'reactstrap';
  13. import { debounce } from 'throttle-debounce';
  14. import { useCreateTemplatePage } from '~/client/services/create-page';
  15. import { useCreatePage } from '~/client/services/create-page/use-create-page';
  16. import { useToastrOnError } from '~/client/services/use-toastr-on-error';
  17. import { useCurrentUser, useIsSearchServiceReachable } from '~/stores-universal/context';
  18. import { usePageCreateModal } from '~/stores/modal';
  19. import PagePathAutoComplete from './PagePathAutoComplete';
  20. import styles from './PageCreateModal.module.scss';
  21. const {
  22. isCreatablePage, isUsersHomepage,
  23. } = pagePathUtils;
  24. const PageCreateModal: React.FC = () => {
  25. const { t } = useTranslation();
  26. const { data: currentUser } = useCurrentUser();
  27. const { data: pageCreateModalData, close: closeCreateModal } = usePageCreateModal();
  28. const isOpened = pageCreateModalData?.isOpened ?? false;
  29. const { create } = useCreatePage();
  30. const { createTemplate } = useCreateTemplatePage();
  31. const { data: isReachable } = useIsSearchServiceReachable();
  32. const pathname = pageCreateModalData?.path ?? '';
  33. const userHomepagePath = pagePathUtils.userHomepagePath(currentUser);
  34. const isCreatable = isCreatablePage(pathname) || isUsersHomepage(pathname);
  35. const pageNameInputInitialValue = isCreatable ? pathUtils.addTrailingSlash(pathname) : '/';
  36. const now = format(new Date(), 'yyyy/MM/dd');
  37. const todaysParentPath = [userHomepagePath, t('create_page_dropdown.todays.memo', { ns: 'commons' }), now].join('/');
  38. const [todayInput, setTodayInput] = useState('');
  39. const [pageNameInput, setPageNameInput] = useState(pageNameInputInitialValue);
  40. const [template, setTemplate] = useState(null);
  41. const [isMatchedWithUserHomepagePath, setIsMatchedWithUserHomepagePath] = useState(false);
  42. const checkIsUsersHomepageDebounce = useMemo(() => {
  43. const checkIsUsersHomepage = () => {
  44. setIsMatchedWithUserHomepagePath(isUsersHomepage(pageNameInput));
  45. };
  46. return debounce(1000, checkIsUsersHomepage);
  47. }, [pageNameInput]);
  48. useEffect(() => {
  49. if (isOpened) {
  50. checkIsUsersHomepageDebounce();
  51. }
  52. }, [isOpened, checkIsUsersHomepageDebounce, pageNameInput]);
  53. function transitBySubmitEvent(e, transitHandler) {
  54. // prevent page transition by submit
  55. e.preventDefault();
  56. transitHandler();
  57. }
  58. /**
  59. * change todayInput
  60. * @param {string} value
  61. */
  62. function onChangeTodayInputHandler(value) {
  63. setTodayInput(value);
  64. }
  65. /**
  66. * change template
  67. * @param {string} value
  68. */
  69. function onChangeTemplateHandler(value) {
  70. setTemplate(value);
  71. }
  72. /**
  73. * access today page
  74. */
  75. const createTodayPage = useCallback(async() => {
  76. const joinedPath = [todaysParentPath, todayInput].join('/');
  77. return create(
  78. {
  79. path: joinedPath, parentPath: todaysParentPath, wip: true, origin: Origin.View,
  80. },
  81. { onTerminated: closeCreateModal },
  82. );
  83. }, [closeCreateModal, create, todayInput, todaysParentPath]);
  84. /**
  85. * access input page
  86. */
  87. const createInputPage = useCallback(async() => {
  88. const targetPath = normalizePath(pageNameInput);
  89. const parentPath = path.dirname(targetPath);
  90. return create(
  91. {
  92. path: targetPath,
  93. parentPath,
  94. wip: true,
  95. origin: Origin.View,
  96. },
  97. { onTerminated: closeCreateModal },
  98. );
  99. }, [closeCreateModal, create, pageNameInput]);
  100. /**
  101. * access template page
  102. */
  103. const createTemplatePage = useCallback(async() => {
  104. const label = (template === 'children') ? '_template' : '__template';
  105. await createTemplate?.(label);
  106. closeCreateModal();
  107. }, [closeCreateModal, createTemplate, template]);
  108. const createTodaysMemoWithToastr = useToastrOnError(createTodayPage);
  109. const createInputPageWithToastr = useToastrOnError(createInputPage);
  110. const createTemplateWithToastr = useToastrOnError(createTemplatePage);
  111. function renderCreateTodayForm() {
  112. if (!isOpened) {
  113. return <></>;
  114. }
  115. return (
  116. <div className="row">
  117. <fieldset className="col-12 mb-4">
  118. <h3 className="pb-2">{t('create_page_dropdown.todays.desc', { ns: 'commons' })}</h3>
  119. <div className="d-sm-flex align-items-center justify-items-between">
  120. <div className="d-flex align-items-center flex-fill flex-wrap flex-lg-nowrap">
  121. <div className="d-flex align-items-center text-nowrap">
  122. <span>{todaysParentPath}/</span>
  123. </div>
  124. <form className="mt-1 mt-lg-0 ms-lg-2 w-100" onSubmit={(e) => { transitBySubmitEvent(e, createTodaysMemoWithToastr) }}>
  125. <input
  126. type="text"
  127. className="page-today-input2 form-control w-100"
  128. id="page-today-input2"
  129. placeholder={t('Input page name (optional)')}
  130. value={todayInput}
  131. onChange={e => onChangeTodayInputHandler(e.target.value)}
  132. />
  133. </form>
  134. </div>
  135. <div className="d-flex justify-content-end mt-1 mt-sm-0">
  136. <button
  137. type="button"
  138. data-testid="btn-create-memo"
  139. className="grw-btn-create-page btn btn-outline-primary rounded-pill text-nowrap ms-3"
  140. onClick={createTodaysMemoWithToastr}
  141. >
  142. <span className="material-symbols-outlined">description</span>{t('Create')}
  143. </button>
  144. </div>
  145. </div>
  146. </fieldset>
  147. </div>
  148. );
  149. }
  150. function renderInputPageForm() {
  151. if (!isOpened) {
  152. return <></>;
  153. }
  154. return (
  155. <div className="row" data-testid="row-create-page-under-below">
  156. <fieldset className="col-12 mb-4">
  157. <h3 className="pb-2">{t('Create under')}</h3>
  158. <div className="d-sm-flex align-items-center justify-items-between">
  159. <div className="flex-fill">
  160. {isReachable
  161. ? (
  162. <PagePathAutoComplete
  163. initializedPath={pageNameInputInitialValue}
  164. addTrailingSlash
  165. onSubmit={createInputPageWithToastr}
  166. onInputChange={value => setPageNameInput(value)}
  167. autoFocus
  168. />
  169. )
  170. : (
  171. <form onSubmit={(e) => { transitBySubmitEvent(e, createInputPageWithToastr) }}>
  172. <input
  173. type="text"
  174. value={pageNameInput}
  175. className="form-control flex-fill"
  176. placeholder={t('Input page name')}
  177. onChange={e => setPageNameInput(e.target.value)}
  178. required
  179. />
  180. </form>
  181. )}
  182. </div>
  183. <div className="d-flex justify-content-end mt-1 mt-sm-0">
  184. <button
  185. type="button"
  186. data-testid="btn-create-page-under-below"
  187. className="grw-btn-create-page btn btn-outline-primary rounded-pill text-nowrap ms-3"
  188. onClick={createInputPageWithToastr}
  189. disabled={isMatchedWithUserHomepagePath}
  190. >
  191. <span className="material-symbols-outlined">description</span>{t('Create')}
  192. </button>
  193. </div>
  194. </div>
  195. { isMatchedWithUserHomepagePath && (
  196. <p className="text-danger mt-2">Error: Cannot create page under /user page directory.</p>
  197. ) }
  198. </fieldset>
  199. </div>
  200. );
  201. }
  202. function renderTemplatePageForm() {
  203. if (!isOpened) {
  204. return <></>;
  205. }
  206. return (
  207. <div className="row">
  208. <fieldset className="col-12">
  209. <h3 className="pb-2">
  210. {t('template.modal_label.Create template under')}<br />
  211. <code className="h6" data-testid="grw-page-create-modal-path-name">{pathname}</code>
  212. </h3>
  213. <div className="d-sm-flex align-items-center justify-items-between">
  214. <UncontrolledButtonDropdown id="dd-template-type" className="flex-fill text-center">
  215. <DropdownToggle id="template-type" caret>
  216. {template == null && t('template.option_label.select')}
  217. {template === 'children' && t('template.children.label')}
  218. {template === 'descendants' && t('template.descendants.label')}
  219. </DropdownToggle>
  220. <DropdownMenu>
  221. <DropdownItem onClick={() => onChangeTemplateHandler('children')}>
  222. {t('template.children.label')} (_template)<br className="d-block d-md-none" />
  223. <small className="text-muted text-wrap">- {t('template.children.desc')}</small>
  224. </DropdownItem>
  225. <DropdownItem onClick={() => onChangeTemplateHandler('descendants')}>
  226. {t('template.descendants.label')} (__template) <br className="d-block d-md-none" />
  227. <small className="text-muted">- {t('template.descendants.desc')}</small>
  228. </DropdownItem>
  229. </DropdownMenu>
  230. </UncontrolledButtonDropdown>
  231. <div className="d-flex justify-content-end mt-1 mt-sm-0">
  232. <button
  233. data-testid="grw-btn-edit-page"
  234. type="button"
  235. className="grw-btn-create-page btn btn-outline-primary rounded-pill text-nowrap ms-3"
  236. onClick={createTemplateWithToastr}
  237. disabled={template == null}
  238. >
  239. <span className="material-symbols-outlined">description</span>{t('Edit')}
  240. </button>
  241. </div>
  242. </div>
  243. </fieldset>
  244. </div>
  245. );
  246. }
  247. return (
  248. <Modal
  249. size="lg"
  250. isOpen={isOpened}
  251. toggle={() => closeCreateModal()}
  252. data-testid="page-create-modal"
  253. className={`grw-create-page ${styles['grw-create-page']}`}
  254. autoFocus={false}
  255. >
  256. <ModalHeader tag="h4" toggle={() => closeCreateModal()}>
  257. {t('New Page')}
  258. </ModalHeader>
  259. <ModalBody>
  260. {renderCreateTodayForm()}
  261. {renderInputPageForm()}
  262. {renderTemplatePageForm()}
  263. </ModalBody>
  264. </Modal>
  265. );
  266. };
  267. export default PageCreateModal;