SavePageControls.tsx 10.0 KB

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