CommentEditor.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. import React, {
  2. useCallback, useState, useRef, useEffect,
  3. } from 'react';
  4. import { UserPicture } from '@growi/ui/dist/components/User/UserPicture';
  5. import dynamic from 'next/dynamic';
  6. import {
  7. Button, TabContent, TabPane,
  8. } from 'reactstrap';
  9. import * as toastr from 'toastr';
  10. import { apiPostForm } from '~/client/util/apiv1-client';
  11. import { IEditorMethods } from '~/interfaces/editor-methods';
  12. import { useSWRxPageComment } from '~/stores/comment';
  13. import {
  14. useCurrentUser, useIsSlackConfigured,
  15. useIsUploadableFile, useIsUploadableImage,
  16. } from '~/stores/context';
  17. import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
  18. import { useCurrentPagePath } from '~/stores/page';
  19. import { CustomNavTab } from '../CustomNavigation/CustomNav';
  20. import { NotAvailableForGuest } from '../NotAvailableForGuest';
  21. import Editor from '../PageEditor/Editor';
  22. import { CommentPreview } from './CommentPreview';
  23. import styles from './CommentEditor.module.scss';
  24. const SlackNotification = dynamic(() => import('../SlackNotification').then(mod => mod.SlackNotification), { ssr: false });
  25. const navTabMapping = {
  26. comment_editor: {
  27. Icon: () => <i className="icon-settings" />,
  28. i18n: 'Write',
  29. },
  30. comment_preview: {
  31. Icon: () => <i className="icon-settings" />,
  32. i18n: 'Preview',
  33. },
  34. };
  35. export type CommentEditorProps = {
  36. pageId: string,
  37. isForNewComment?: boolean,
  38. replyTo?: string,
  39. revisionId: string,
  40. currentCommentId?: string,
  41. commentBody?: string,
  42. onCancelButtonClicked?: () => void,
  43. onCommentButtonClicked?: () => void,
  44. }
  45. export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
  46. const {
  47. pageId, isForNewComment, replyTo, revisionId,
  48. currentCommentId, commentBody, onCancelButtonClicked, onCommentButtonClicked,
  49. } = props;
  50. const { data: currentUser } = useCurrentUser();
  51. const { data: currentPagePath } = useCurrentPagePath();
  52. const { update: updateComment, post: postComment } = useSWRxPageComment(pageId);
  53. const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
  54. const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
  55. const { data: isSlackConfigured } = useIsSlackConfigured();
  56. const { data: isUploadableFile } = useIsUploadableFile();
  57. const { data: isUploadableImage } = useIsUploadableImage();
  58. const [isReadyToUse, setIsReadyToUse] = useState(!isForNewComment);
  59. const [comment, setComment] = useState(commentBody ?? '');
  60. const [activeTab, setActiveTab] = useState('comment_editor');
  61. const [error, setError] = useState();
  62. const [slackChannels, setSlackChannels] = useState<string>('');
  63. const editorRef = useRef<IEditorMethods>(null);
  64. const handleSelect = useCallback((activeTab: string) => {
  65. setActiveTab(activeTab);
  66. }, []);
  67. // DO NOT dependent on slackChannelsData directly: https://github.com/weseek/growi/pull/7332
  68. const slackChannelsDataString = slackChannelsData?.toString();
  69. const initializeSlackEnabled = useCallback(() => {
  70. setSlackChannels(slackChannelsDataString ?? '');
  71. mutateIsSlackEnabled(false);
  72. }, [mutateIsSlackEnabled, slackChannelsDataString]);
  73. useEffect(() => {
  74. initializeSlackEnabled();
  75. }, [initializeSlackEnabled]);
  76. const isSlackEnabledToggleHandler = (isSlackEnabled: boolean) => {
  77. mutateIsSlackEnabled(isSlackEnabled, false);
  78. };
  79. const slackChannelsChangedHandler = useCallback((slackChannels: string) => {
  80. setSlackChannels(slackChannels);
  81. }, []);
  82. const initializeEditor = useCallback(() => {
  83. setComment('');
  84. setActiveTab('comment_editor');
  85. setError(undefined);
  86. initializeSlackEnabled();
  87. // reset value
  88. if (editorRef.current == null) { return }
  89. editorRef.current.setValue('');
  90. }, [initializeSlackEnabled]);
  91. const cancelButtonClickedHandler = useCallback(() => {
  92. // change state to not ready
  93. // when this editor is for the new comment mode
  94. if (isForNewComment) {
  95. setIsReadyToUse(false);
  96. }
  97. if (onCancelButtonClicked != null) {
  98. onCancelButtonClicked();
  99. }
  100. }, [isForNewComment, onCancelButtonClicked]);
  101. const postCommentHandler = useCallback(async() => {
  102. try {
  103. if (currentCommentId != null) {
  104. // update current comment
  105. await updateComment(comment, revisionId, currentCommentId);
  106. }
  107. else {
  108. // post new comment
  109. const postCommentArgs = {
  110. commentForm: {
  111. comment,
  112. revisionId,
  113. replyTo,
  114. },
  115. slackNotificationForm: {
  116. isSlackEnabled,
  117. slackChannels,
  118. },
  119. };
  120. await postComment(postCommentArgs);
  121. }
  122. initializeEditor();
  123. if (onCommentButtonClicked != null) {
  124. onCommentButtonClicked();
  125. }
  126. }
  127. catch (err) {
  128. const errorMessage = err.message || 'An unknown error occured when posting comment';
  129. setError(errorMessage);
  130. }
  131. }, [
  132. comment, currentCommentId, initializeEditor,
  133. isSlackEnabled, onCommentButtonClicked, replyTo, slackChannels,
  134. postComment, revisionId, updateComment,
  135. ]);
  136. const ctrlEnterHandler = useCallback((event) => {
  137. if (event != null) {
  138. event.preventDefault();
  139. }
  140. postCommentHandler();
  141. }, [postCommentHandler]);
  142. const apiErrorHandler = useCallback((error: Error) => {
  143. toastr.error(error.message, 'Error occured', {
  144. closeButton: true,
  145. progressBar: true,
  146. newestOnTop: false,
  147. showDuration: '100',
  148. hideDuration: '100',
  149. timeOut: '3000',
  150. });
  151. }, []);
  152. const uploadHandler = useCallback(async(file) => {
  153. if (editorRef.current == null) { return }
  154. const pagePath = currentPagePath;
  155. const endpoint = '/attachments.add';
  156. const formData = new FormData();
  157. formData.append('file', file);
  158. formData.append('path', pagePath ?? '');
  159. formData.append('page_id', pageId ?? '');
  160. try {
  161. // TODO: typescriptize res
  162. const res = await apiPostForm(endpoint, formData) as any;
  163. const attachment = res.attachment;
  164. const fileName = attachment.originalName;
  165. let insertText = `[${fileName}](${attachment.filePathProxied})`;
  166. // when image
  167. if (attachment.fileFormat.startsWith('image/')) {
  168. // modify to "![fileName](url)" syntax
  169. insertText = `!${insertText}`;
  170. }
  171. editorRef.current.insertText(insertText);
  172. }
  173. catch (err) {
  174. apiErrorHandler(err);
  175. }
  176. finally {
  177. editorRef.current.terminateUploadingState();
  178. }
  179. }, [apiErrorHandler, currentPagePath, pageId]);
  180. const getCommentHtml = useCallback(() => {
  181. if (currentPagePath == null) {
  182. return <></>;
  183. }
  184. return <CommentPreview markdown={comment} />;
  185. }, [currentPagePath, comment]);
  186. const renderBeforeReady = useCallback((): JSX.Element => {
  187. return (
  188. <div className="text-center">
  189. <NotAvailableForGuest>
  190. <button
  191. type="button"
  192. className="btn btn-lg btn-link"
  193. onClick={() => setIsReadyToUse(true)}
  194. data-testid="open-comment-editor-button"
  195. >
  196. <i className="icon-bubble"></i> Add Comment
  197. </button>
  198. </NotAvailableForGuest>
  199. </div>
  200. );
  201. }, []);
  202. const onChangeHandler = useCallback((newValue: string) => setComment(newValue), []);
  203. const renderReady = () => {
  204. const commentPreview = getCommentHtml();
  205. const errorMessage = <span className="text-danger text-right mr-2">{error}</span>;
  206. const cancelButton = (
  207. <Button
  208. outline
  209. color="danger"
  210. size="xs"
  211. className="btn btn-outline-danger rounded-pill"
  212. onClick={cancelButtonClickedHandler}
  213. >
  214. Cancel
  215. </Button>
  216. );
  217. const submitButton = (
  218. <Button
  219. outline
  220. color="primary"
  221. className="btn btn-outline-primary rounded-pill"
  222. onClick={postCommentHandler}
  223. >
  224. Comment
  225. </Button>
  226. );
  227. const isUploadable = isUploadableImage || isUploadableFile;
  228. return (
  229. <>
  230. <div className="comment-write">
  231. <CustomNavTab activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={handleSelect} hideBorderBottom />
  232. <TabContent activeTab={activeTab}>
  233. <TabPane tabId="comment_editor">
  234. <Editor
  235. ref={editorRef}
  236. value={commentBody ?? ''} // DO NOT use state
  237. isUploadable={isUploadable}
  238. isUploadableFile={isUploadableFile}
  239. onChange={onChangeHandler}
  240. onUpload={uploadHandler}
  241. onCtrlEnter={ctrlEnterHandler}
  242. isComment
  243. />
  244. {/*
  245. Note: <OptionsSelector /> is not optimized for ComentEditor in terms of responsive design.
  246. See a review comment in https://github.com/weseek/growi/pull/3473
  247. */}
  248. </TabPane>
  249. <TabPane tabId="comment_preview">
  250. <div className="comment-form-preview">
  251. {commentPreview}
  252. </div>
  253. </TabPane>
  254. </TabContent>
  255. </div>
  256. <div className="comment-submit">
  257. <div className="d-flex">
  258. <span className="flex-grow-1" />
  259. <span className="d-none d-sm-inline">{errorMessage && errorMessage}</span>
  260. {isSlackConfigured && isSlackEnabled != null
  261. && (
  262. <div className="form-inline align-self-center mr-md-2">
  263. <SlackNotification
  264. isSlackEnabled={isSlackEnabled}
  265. slackChannels={slackChannels}
  266. onEnabledFlagChange={isSlackEnabledToggleHandler}
  267. onChannelChange={slackChannelsChangedHandler}
  268. id="idForComment"
  269. />
  270. </div>
  271. )
  272. }
  273. <div className="d-none d-sm-block">
  274. <span className="mr-2">{cancelButton}</span><span>{submitButton}</span>
  275. </div>
  276. </div>
  277. <div className="d-block d-sm-none mt-2">
  278. <div className="d-flex justify-content-end">
  279. {error && errorMessage}
  280. <span className="mr-2">{cancelButton}</span><span>{submitButton}</span>
  281. </div>
  282. </div>
  283. </div>
  284. </>
  285. );
  286. };
  287. return (
  288. <div className={`${styles['comment-editor-styles']} form page-comment-form`}>
  289. <div className="comment-form">
  290. <div className="comment-form-user">
  291. <UserPicture user={currentUser} noLink noTooltip />
  292. </div>
  293. <div className="comment-form-main">
  294. {isReadyToUse
  295. ? renderReady()
  296. : renderBeforeReady()
  297. }
  298. </div>
  299. </div>
  300. </div>
  301. );
  302. };