SavePageControls.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. import React, { useCallback, useState, useEffect } from 'react';
  2. import type EventEmitter from 'events';
  3. import { isTopPage, isUsersProtectedPages } from '@growi/core/dist/utils/page-path-utils';
  4. import { LoadingSpinner } from '@growi/ui/dist/components';
  5. import { useTranslation } from 'next-i18next';
  6. import {
  7. UncontrolledButtonDropdown, Button,
  8. DropdownToggle, DropdownMenu, DropdownItem, Modal,
  9. } from 'reactstrap';
  10. import { toastSuccess, toastError } from '~/client/util/toastr';
  11. import type { IPageGrantData } from '~/interfaces/page';
  12. import {
  13. useIsEditable, useIsAclEnabled,
  14. useIsSlackConfigured,
  15. } from '~/stores/context';
  16. import { useWaitingSaveProcessing, useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
  17. import { useSWRMUTxCurrentPage, useSWRxCurrentPage, useCurrentPagePath } from '~/stores/page';
  18. import { mutatePageTree } from '~/stores/page-listing';
  19. import {
  20. useSelectedGrant,
  21. useEditorMode, useIsDeviceLargerThanMd,
  22. EditorMode,
  23. } from '~/stores/ui';
  24. import loggerFactory from '~/utils/logger';
  25. import { unpublish } from '../client/services/page-operation';
  26. import { GrantSelector } from './SavePageControls/GrantSelector';
  27. import { SlackNotification } from './SlackNotification';
  28. declare global {
  29. // eslint-disable-next-line vars-on-top, no-var
  30. var globalEmitter: EventEmitter;
  31. }
  32. const logger = loggerFactory('growi:SavePageControls');
  33. const SavePageButton = (props: {slackChannels: string, isDeviceLargerThanMd?: boolean}) => {
  34. const { t } = useTranslation();
  35. const { data: currentPage } = useSWRxCurrentPage();
  36. const { data: _isWaitingSaveProcessing } = useWaitingSaveProcessing();
  37. const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
  38. const { mutate: mutateEditorMode } = useEditorMode();
  39. const [isSavePageModalShown, setIsSavePageModalShown] = useState<boolean>(false);
  40. const { slackChannels, isDeviceLargerThanMd } = props;
  41. const isWaitingSaveProcessing = _isWaitingSaveProcessing === true; // ignore undefined
  42. const save = useCallback(async(): Promise<void> => {
  43. // save
  44. globalEmitter.emit('saveAndReturnToView', { slackChannels });
  45. }, [slackChannels]);
  46. const saveAndOverwriteScopesOfDescendants = useCallback(() => {
  47. // save
  48. globalEmitter.emit('saveAndReturnToView', { overwriteScopesOfDescendants: true, slackChannels });
  49. }, [slackChannels]);
  50. const clickUnpublishButtonHandler = useCallback(async() => {
  51. const pageId = currentPage?._id;
  52. if (pageId == null) {
  53. return;
  54. }
  55. try {
  56. await unpublish(pageId);
  57. await mutateCurrentPage();
  58. await mutatePageTree();
  59. await mutateEditorMode(EditorMode.View);
  60. toastSuccess(t('wip_page.success_save_as_wip'));
  61. }
  62. catch (err) {
  63. logger.error(err);
  64. toastError(t('wip_page.fail_save_as_wip'));
  65. }
  66. }, [currentPage?._id, mutateCurrentPage, mutateEditorMode, t]);
  67. const labelSubmitButton = t('Update');
  68. const labelOverwriteScopes = t('page_edit.overwrite_scopes', { operation: labelSubmitButton });
  69. const labelUnpublishPage = t('wip_page.save_as_wip');
  70. return (
  71. <>
  72. <UncontrolledButtonDropdown direction="up" size="sm">
  73. <Button
  74. id="caret"
  75. data-testid="save-page-btn"
  76. color="primary"
  77. className="btn-submit"
  78. onClick={save}
  79. disabled={isWaitingSaveProcessing}
  80. >
  81. {isWaitingSaveProcessing && (
  82. <LoadingSpinner />
  83. )}
  84. {labelSubmitButton}
  85. </Button>
  86. {
  87. isDeviceLargerThanMd ? (
  88. <>
  89. <DropdownToggle caret color="primary" disabled={isWaitingSaveProcessing} />
  90. <DropdownMenu container="body" end>
  91. <DropdownItem onClick={saveAndOverwriteScopesOfDescendants}>
  92. {labelOverwriteScopes}
  93. </DropdownItem>
  94. <DropdownItem onClick={clickUnpublishButtonHandler}>
  95. {labelUnpublishPage}
  96. </DropdownItem>
  97. </DropdownMenu>
  98. </>
  99. ) : (
  100. <>
  101. <DropdownToggle caret color="primary" disabled={isWaitingSaveProcessing} onClick={() => setIsSavePageModalShown(true)} />
  102. <Modal
  103. centered
  104. isOpen={isSavePageModalShown}
  105. toggle={() => setIsSavePageModalShown(false)}
  106. >
  107. <div className="d-flex flex-column pt-4 pb-3 px-4 gap-4">
  108. <button type="button" className="btn btn-primary" onClick={() => { setIsSavePageModalShown(false); saveAndOverwriteScopesOfDescendants() }}>
  109. {labelOverwriteScopes}
  110. </button>
  111. <button type="button" className="btn btn-primary" onClick={() => { setIsSavePageModalShown(false); clickUnpublishButtonHandler() }}>
  112. {labelUnpublishPage}
  113. </button>
  114. <button type="button" className="btn btn-outline-neutral-secondary mx-auto mt-1" onClick={() => setIsSavePageModalShown(false)}>
  115. <label className="mx-2">
  116. {t('Cancel')}
  117. </label>
  118. </button>
  119. </div>
  120. </Modal>
  121. </>
  122. )
  123. }
  124. </UncontrolledButtonDropdown>
  125. </>
  126. );
  127. };
  128. export const SavePageControls = (): JSX.Element | null => {
  129. const { t } = useTranslation('commons');
  130. const { data: currentPage } = useSWRxCurrentPage();
  131. const { data: isEditable } = useIsEditable();
  132. const { data: isAclEnabled } = useIsAclEnabled();
  133. const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
  134. const { data: editorMode } = useEditorMode();
  135. const { data: currentPagePath } = useCurrentPagePath();
  136. const { data: isSlackConfigured } = useIsSlackConfigured();
  137. const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
  138. const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
  139. const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
  140. const [slackChannels, setSlackChannels] = useState<string>('');
  141. const [isSavePageControlsModalShown, setIsSavePageControlsModalShown] = useState<boolean>(false);
  142. // DO NOT dependent on slackChannelsData directly: https://github.com/weseek/growi/pull/7332
  143. const slackChannelsDataString = slackChannelsData?.toString();
  144. useEffect(() => {
  145. if (editorMode === 'editor') {
  146. setSlackChannels(slackChannelsDataString ?? '');
  147. mutateIsSlackEnabled(false);
  148. }
  149. }, [editorMode, mutateIsSlackEnabled, slackChannelsDataString]);
  150. const isSlackEnabledToggleHandler = (bool: boolean) => {
  151. mutateIsSlackEnabled(bool, false);
  152. };
  153. const slackChannelsChangedHandler = useCallback((slackChannels: string) => {
  154. setSlackChannels(slackChannels);
  155. }, []);
  156. const updateGrantHandler = useCallback((grantData: IPageGrantData): void => {
  157. mutateGrant(grantData);
  158. }, [mutateGrant]);
  159. if (isEditable == null || isAclEnabled == null || grantData == null) {
  160. return null;
  161. }
  162. if (!isEditable) {
  163. return null;
  164. }
  165. const { grant, userRelatedGrantedGroups } = grantData;
  166. const isGrantSelectorDisabledPage = isTopPage(currentPage?.path ?? '') || isUsersProtectedPages(currentPage?.path ?? '');
  167. return (
  168. <div className="d-flex align-items-center flex-nowrap">
  169. {
  170. isDeviceLargerThanMd ? (
  171. <>
  172. {
  173. isSlackConfigured && (
  174. <div className="me-2">
  175. {isSlackEnabled != null && (
  176. <SlackNotification
  177. isSlackEnabled={isSlackEnabled}
  178. slackChannels={slackChannels}
  179. onEnabledFlagChange={isSlackEnabledToggleHandler}
  180. onChannelChange={slackChannelsChangedHandler}
  181. id="idForEditorNavbarBottom"
  182. />
  183. )}
  184. </div>
  185. )
  186. }
  187. {
  188. isAclEnabled && (
  189. <div className="me-2">
  190. <GrantSelector
  191. grant={grant}
  192. disabled={isGrantSelectorDisabledPage}
  193. userRelatedGrantedGroups={userRelatedGrantedGroups}
  194. onUpdateGrant={updateGrantHandler}
  195. />
  196. </div>
  197. )
  198. }
  199. <SavePageButton slackChannels={slackChannels} isDeviceLargerThanMd />
  200. </>
  201. ) : (
  202. <>
  203. <SavePageButton slackChannels={slackChannels} />
  204. <button
  205. type="button"
  206. className="btn btn-outline-neutral-secondary border-0 fs-5 p-0 ms-1 text-muted"
  207. onClick={() => setIsSavePageControlsModalShown(true)}
  208. >
  209. <span className="material-symbols-outlined">more_vert</span>
  210. </button>
  211. <Modal
  212. className="save-page-controls-modal"
  213. centered
  214. isOpen={isSavePageControlsModalShown}
  215. >
  216. <div className="d-flex flex-column pt-5 pb-3 px-4 gap-3">
  217. {
  218. isAclEnabled && (
  219. <>
  220. <GrantSelector
  221. grant={grant}
  222. disabled={isGrantSelectorDisabledPage}
  223. openInModal
  224. userRelatedGrantedGroups={userRelatedGrantedGroups}
  225. onUpdateGrant={updateGrantHandler}
  226. />
  227. </>
  228. )
  229. }
  230. {
  231. isSlackConfigured && isSlackEnabled != null && (
  232. <>
  233. <SlackNotification
  234. isSlackEnabled={isSlackEnabled}
  235. slackChannels={slackChannels}
  236. onEnabledFlagChange={isSlackEnabledToggleHandler}
  237. onChannelChange={slackChannelsChangedHandler}
  238. id="idForEditorNavbarBottom"
  239. />
  240. </>
  241. )
  242. }
  243. <div className="d-flex">
  244. <button type="button" className="mx-auto btn btn-primary rounded-1" onClick={() => setIsSavePageControlsModalShown(false)}>
  245. {t('Done')}
  246. </button>
  247. </div>
  248. </div>
  249. </Modal>
  250. </>
  251. )
  252. }
  253. </div>
  254. );
  255. };