PageCreateModal.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  1. import type React from 'react';
  2. import { useCallback, useEffect, useMemo, useState } from 'react';
  3. import { Origin } from '@growi/core';
  4. import { pagePathUtils, pathUtils } from '@growi/core/dist/utils';
  5. import { normalizePath } from '@growi/core/dist/utils/path-utils';
  6. import { format } from 'date-fns/format';
  7. import { useAtomValue } from 'jotai';
  8. import { useTranslation } from 'next-i18next';
  9. import path from 'path';
  10. import {
  11. DropdownItem,
  12. DropdownMenu,
  13. DropdownToggle,
  14. Modal,
  15. ModalBody,
  16. ModalHeader,
  17. UncontrolledButtonDropdown,
  18. } from 'reactstrap';
  19. import { debounce } from 'throttle-debounce';
  20. import { useCreateTemplatePage } from '~/client/services/create-page';
  21. import { useCreatePage } from '~/client/services/create-page/use-create-page';
  22. import { useToastrOnError } from '~/client/services/use-toastr-on-error';
  23. import { useGrowiDocumentationUrl } from '~/states/context';
  24. import { useCurrentUser } from '~/states/global';
  25. import { isSearchServiceReachableAtom } from '~/states/server-configurations';
  26. import {
  27. usePageCreateModalActions,
  28. usePageCreateModalStatus,
  29. } from '~/states/ui/modal/page-create';
  30. import { getLocale } from '~/utils/locale-utils';
  31. import PagePathAutoComplete from './PagePathAutoComplete';
  32. import styles from './PageCreateModal.module.scss';
  33. const { isCreatablePage, isUsersHomepage } = pagePathUtils;
  34. const PageCreateModal: React.FC = () => {
  35. const { t, i18n } = useTranslation();
  36. const currentUser = useCurrentUser();
  37. const documentationUrl = useGrowiDocumentationUrl();
  38. const { isOpened, path: pathname = '' } = usePageCreateModalStatus();
  39. const { close: closeCreateModal } = usePageCreateModalActions();
  40. const { create } = useCreatePage();
  41. const { createTemplate } = useCreateTemplatePage();
  42. const isReachable = useAtomValue(isSearchServiceReachableAtom);
  43. // Memoize computed values
  44. const userHomepagePath = useMemo(
  45. () => pagePathUtils.userHomepagePath(currentUser),
  46. [currentUser],
  47. );
  48. const isCreatable = useMemo(
  49. () => isCreatablePage(pathname) || isUsersHomepage(pathname),
  50. [pathname],
  51. );
  52. const pageNameInputInitialValue = useMemo(
  53. () => (isCreatable ? pathUtils.addTrailingSlash(pathname) : '/'),
  54. [isCreatable, pathname],
  55. );
  56. const now = useMemo(() => format(new Date(), 'yyyy/MM/dd'), []);
  57. const todaysParentPath = useMemo(
  58. () =>
  59. [
  60. userHomepagePath,
  61. t('create_page_dropdown.todays.memo', { ns: 'commons' }),
  62. now,
  63. ].join('/'),
  64. [userHomepagePath, t, now],
  65. );
  66. const docsLang = getLocale(i18n.language).code === 'ja' ? 'ja' : 'en';
  67. const templateHelpUrl = `${documentationUrl}/${docsLang}/guide/features/template.html`;
  68. const [todayInput, setTodayInput] = useState('');
  69. const [pageNameInput, setPageNameInput] = useState(pageNameInputInitialValue);
  70. const [template, setTemplate] = useState(null);
  71. const [isMatchedWithUserHomepagePath, setIsMatchedWithUserHomepagePath] =
  72. useState(false);
  73. const checkIsUsersHomepageDebounce = useMemo(() => {
  74. return debounce(1000, (input: string) => {
  75. setIsMatchedWithUserHomepagePath(isUsersHomepage(input));
  76. });
  77. }, []);
  78. useEffect(() => {
  79. if (isOpened) {
  80. checkIsUsersHomepageDebounce(pageNameInput);
  81. }
  82. }, [isOpened, checkIsUsersHomepageDebounce, pageNameInput]);
  83. const transitBySubmitEvent = useCallback((e, transitHandler) => {
  84. // prevent page transition by submit
  85. e.preventDefault();
  86. transitHandler();
  87. }, []);
  88. /**
  89. * change todayInput
  90. * @param {string} value
  91. */
  92. const onChangeTodayInputHandler = useCallback((value) => {
  93. setTodayInput(value);
  94. }, []);
  95. /**
  96. * change template
  97. * @param {string} value
  98. */
  99. const onChangeTemplateHandler = useCallback((value) => {
  100. setTemplate(value);
  101. }, []);
  102. /**
  103. * access today page
  104. */
  105. const createTodayPage = useCallback(async () => {
  106. const joinedPath = [todaysParentPath, todayInput].join('/');
  107. return create(
  108. {
  109. path: joinedPath,
  110. parentPath: todaysParentPath,
  111. wip: true,
  112. origin: Origin.View,
  113. },
  114. { onTerminated: closeCreateModal },
  115. );
  116. }, [closeCreateModal, create, todayInput, todaysParentPath]);
  117. /**
  118. * access input page
  119. */
  120. const createInputPage = useCallback(async () => {
  121. const targetPath = normalizePath(pageNameInput);
  122. const parentPath = path.dirname(targetPath);
  123. return create(
  124. {
  125. path: targetPath,
  126. parentPath,
  127. wip: true,
  128. origin: Origin.View,
  129. },
  130. { onTerminated: closeCreateModal },
  131. );
  132. }, [closeCreateModal, create, pageNameInput]);
  133. /**
  134. * access template page
  135. */
  136. const createTemplatePage = useCallback(async () => {
  137. const label = template === 'children' ? '_template' : '__template';
  138. await createTemplate?.(label);
  139. closeCreateModal();
  140. }, [closeCreateModal, createTemplate, template]);
  141. const createTodaysMemoWithToastr = useToastrOnError(createTodayPage);
  142. const createInputPageWithToastr = useToastrOnError(createInputPage);
  143. const createTemplateWithToastr = useToastrOnError(createTemplatePage);
  144. const renderCreateTodayForm = useMemo(() => {
  145. if (!isOpened) {
  146. return <></>;
  147. }
  148. return (
  149. <div className="row">
  150. <fieldset className="col-12 mb-4">
  151. <h3 className="pb-2">
  152. {t('create_page_dropdown.todays.desc', { ns: 'commons' })}
  153. </h3>
  154. <div className="d-sm-flex align-items-center justify-items-between">
  155. <div className="d-flex align-items-center flex-fill flex-wrap flex-lg-nowrap">
  156. <div className="d-flex align-items-center text-nowrap">
  157. <span>{todaysParentPath}/</span>
  158. </div>
  159. <form
  160. className="mt-1 mt-lg-0 ms-lg-2 w-100"
  161. onSubmit={(e) => {
  162. transitBySubmitEvent(e, createTodaysMemoWithToastr);
  163. }}
  164. >
  165. <input
  166. type="text"
  167. className="page-today-input2 form-control w-100"
  168. id="page-today-input2"
  169. placeholder={t('Input page name (optional)')}
  170. value={todayInput}
  171. onChange={(e) => onChangeTodayInputHandler(e.target.value)}
  172. />
  173. </form>
  174. </div>
  175. <div className="d-flex justify-content-end mt-1 mt-sm-0">
  176. <button
  177. type="button"
  178. data-testid="btn-create-memo"
  179. className="grw-btn-create-page btn btn-outline-primary rounded-pill text-nowrap ms-3"
  180. onClick={createTodaysMemoWithToastr}
  181. >
  182. <span className="material-symbols-outlined">description</span>
  183. {t('Create')}
  184. </button>
  185. </div>
  186. </div>
  187. </fieldset>
  188. </div>
  189. );
  190. }, [
  191. isOpened,
  192. todaysParentPath,
  193. todayInput,
  194. t,
  195. onChangeTodayInputHandler,
  196. transitBySubmitEvent,
  197. createTodaysMemoWithToastr,
  198. ]);
  199. const renderInputPageForm = useMemo(() => {
  200. if (!isOpened) {
  201. return <></>;
  202. }
  203. return (
  204. <div className="row" data-testid="row-create-page-under-below">
  205. <fieldset className="col-12 mb-4">
  206. <h3 className="pb-2">{t('Create under')}</h3>
  207. <div className="d-sm-flex align-items-center justify-items-between">
  208. <div className="flex-fill">
  209. {isReachable ? (
  210. <PagePathAutoComplete
  211. initializedPath={pageNameInputInitialValue}
  212. addTrailingSlash
  213. onSubmit={createInputPageWithToastr}
  214. onInputChange={(value) => setPageNameInput(value)}
  215. autoFocus
  216. />
  217. ) : (
  218. <form
  219. onSubmit={(e) => {
  220. transitBySubmitEvent(e, createInputPageWithToastr);
  221. }}
  222. >
  223. <input
  224. type="text"
  225. value={pageNameInput}
  226. className="form-control flex-fill"
  227. placeholder={t('Input page name')}
  228. onChange={(e) => setPageNameInput(e.target.value)}
  229. required
  230. />
  231. </form>
  232. )}
  233. </div>
  234. <div className="d-flex justify-content-end mt-1 mt-sm-0">
  235. <button
  236. type="button"
  237. data-testid="btn-create-page-under-below"
  238. className="grw-btn-create-page btn btn-outline-primary rounded-pill text-nowrap ms-3"
  239. onClick={createInputPageWithToastr}
  240. disabled={isMatchedWithUserHomepagePath}
  241. >
  242. <span className="material-symbols-outlined">description</span>
  243. {t('Create')}
  244. </button>
  245. </div>
  246. </div>
  247. {isMatchedWithUserHomepagePath && (
  248. <p className="text-danger mt-2">
  249. Error: Cannot create page under /user page directory.
  250. </p>
  251. )}
  252. </fieldset>
  253. </div>
  254. );
  255. }, [
  256. isOpened,
  257. isReachable,
  258. pageNameInputInitialValue,
  259. createInputPageWithToastr,
  260. pageNameInput,
  261. isMatchedWithUserHomepagePath,
  262. t,
  263. transitBySubmitEvent,
  264. ]);
  265. const renderTemplatePageForm = useMemo(() => {
  266. if (!isOpened) {
  267. return <></>;
  268. }
  269. return (
  270. <div className="row">
  271. <fieldset className="col-12">
  272. <h3 className="pb-2">
  273. {t('template.modal_label.Create template under')}
  274. <a
  275. href={templateHelpUrl}
  276. target="_blank"
  277. rel="noopener noreferrer"
  278. className="ms-1"
  279. >
  280. <span className="material-symbols-outlined fs-6 text-secondary">
  281. help
  282. </span>
  283. </a>
  284. <br />
  285. <code className="h6" data-testid="grw-page-create-modal-path-name">
  286. {pathname}
  287. </code>
  288. </h3>
  289. <div className="d-sm-flex align-items-center justify-items-between">
  290. <UncontrolledButtonDropdown
  291. id="dd-template-type"
  292. className="flex-fill text-center"
  293. >
  294. <DropdownToggle id="template-type" caret>
  295. {template == null && t('template.option_label.select')}
  296. {template === 'children' && t('template.children.label')}
  297. {template === 'descendants' && t('template.descendants.label')}
  298. </DropdownToggle>
  299. <DropdownMenu>
  300. <DropdownItem
  301. onClick={() => onChangeTemplateHandler('children')}
  302. >
  303. {t('template.children.label')} (_template)
  304. <br className="d-block d-md-none" />
  305. <small className="text-muted text-wrap">
  306. - {t('template.children.desc')}
  307. </small>
  308. </DropdownItem>
  309. <DropdownItem
  310. onClick={() => onChangeTemplateHandler('descendants')}
  311. >
  312. {t('template.descendants.label')} (__template){' '}
  313. <br className="d-block d-md-none" />
  314. <small className="text-muted">
  315. - {t('template.descendants.desc')}
  316. </small>
  317. </DropdownItem>
  318. </DropdownMenu>
  319. </UncontrolledButtonDropdown>
  320. <div className="d-flex justify-content-end mt-1 mt-sm-0">
  321. <button
  322. data-testid="grw-btn-edit-page"
  323. type="button"
  324. className="grw-btn-create-page btn btn-outline-primary rounded-pill text-nowrap ms-3"
  325. onClick={createTemplateWithToastr}
  326. disabled={template == null}
  327. >
  328. <span className="material-symbols-outlined">description</span>
  329. {t('Edit')}
  330. </button>
  331. </div>
  332. </div>
  333. </fieldset>
  334. </div>
  335. );
  336. }, [
  337. isOpened,
  338. pathname,
  339. template,
  340. templateHelpUrl,
  341. onChangeTemplateHandler,
  342. createTemplateWithToastr,
  343. t,
  344. ]);
  345. return (
  346. <Modal
  347. size="lg"
  348. isOpen={isOpened}
  349. toggle={() => closeCreateModal()}
  350. data-testid="page-create-modal"
  351. className={`grw-create-page ${styles['grw-create-page']}`}
  352. autoFocus={false}
  353. >
  354. <ModalHeader tag="h4" toggle={() => closeCreateModal()}>
  355. {t('New Page')}
  356. </ModalHeader>
  357. <ModalBody>
  358. {renderCreateTodayForm}
  359. {renderInputPageForm}
  360. {renderTemplatePageForm}
  361. </ModalBody>
  362. </Modal>
  363. );
  364. };
  365. export default PageCreateModal;