Parcourir la source

Merge pull request #6472 from weseek/imprv/102140-create-new-pages

Imprv/102140 create and edit pages
cao il y a 3 ans
Parent
commit
221e15fbe4

+ 14 - 9
packages/app/src/client/services/AppContainer.js

@@ -104,25 +104,30 @@ export default class AppContainer extends Container {
     return this.containerInstances[className];
   }
 
+
+  /*
+  * Note: Use globalEmitter instaead of registerComponentInstance and getComponentInstance
+  */
+
   /**
    * Register React component instance
    * @param {string} id
    * @param {object} instance React component instance
    */
-  registerComponentInstance(id, instance) {
-    if (instance == null) {
-      throw new Error('The specified instance must not be null');
-    }
+  // registerComponentInstance(id, instance) {
+  //   if (instance == null) {
+  //     throw new Error('The specified instance must not be null');
+  //   }
 
-    this.componentInstances[id] = instance;
-  }
+  //   this.componentInstances[id] = instance;
+  // }
 
   /**
    * Get registered React component instance
    * @param {string} id
    */
-  getComponentInstance(id) {
-    return this.componentInstances[id];
-  }
+  // getComponentInstance(id) {
+  //   return this.componentInstances[id];
+  // }
 
 }

+ 6 - 0
packages/app/src/client/services/EditorContainer.js

@@ -5,6 +5,12 @@ import loggerFactory from '~/utils/logger';
 const logger = loggerFactory('growi:services:EditorContainer');
 
 
+/*
+* TODO: Omit Unstated: EditorContainer
+* => https://redmine.weseek.co.jp/issues/103246
+*/
+
+
 /**
  * Service container related to options for Editor/Preview
  * @extends {Container} unstated Container

+ 2 - 78
packages/app/src/client/services/PageContainer.js

@@ -7,9 +7,6 @@ import { Container } from 'unstated';
 import { EditorMode } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
-import { toastError } from '../util/apiNotification';
-import { apiPost } from '../util/apiv1-client';
-import { apiv3Post } from '../util/apiv3-client';
 import {
   DetachCodeBlockInterceptor,
   RestoreCodeBlockInterceptor,
@@ -17,6 +14,8 @@ import {
 import {
   DrawioInterceptor,
 } from '../../services/renderer/interceptor/drawio-interceptor';
+import { toastError } from '../util/apiNotification';
+import { apiPost } from '../util/apiv1-client';
 
 const { isTrashPage } = pagePathUtils;
 
@@ -263,81 +262,6 @@ export default class PageContainer extends Container {
     return res;
   }
 
-  async saveAndReload(optionsToSave, editorMode) {
-    if (optionsToSave == null) {
-      const msg = '\'saveAndReload\' requires the \'optionsToSave\' param';
-      throw new Error(msg);
-    }
-
-    if (editorMode == null) {
-      logger.warn('\'saveAndReload\' requires the \'editorMode\' param');
-      return;
-    }
-
-    const { pageId, path } = this.state;
-    let { revisionId } = this.state;
-
-    const options = Object.assign({}, optionsToSave);
-
-    let markdown;
-    if (editorMode === EditorMode.HackMD) {
-      const pageEditorByHackmd = this.appContainer.getComponentInstance('PageEditorByHackmd');
-      markdown = await pageEditorByHackmd.getMarkdown();
-      // set option to sync
-      options.isSyncRevisionToHackmd = true;
-      revisionId = this.state.revisionIdHackmdSynced;
-    }
-    else {
-      const pageEditor = this.appContainer.getComponentInstance('PageEditor');
-      markdown = pageEditor.getMarkdown();
-    }
-
-    let res;
-    if (pageId == null) {
-      res = await this.createPage(path, markdown, options);
-    }
-    else {
-      res = await this.updatePage(pageId, revisionId, markdown, options);
-    }
-
-    const editorContainer = this.appContainer.getContainer('EditorContainer');
-    editorContainer.clearDraft(path);
-    window.location.href = path;
-
-    return res;
-  }
-
-  async createPage(pagePath, markdown, tmpParams) {
-    const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
-
-    // clone
-    const params = Object.assign(tmpParams, {
-      path: pagePath,
-      body: markdown,
-    });
-
-    const res = await apiv3Post('/pages/', params);
-    const { page, tags, revision } = res.data;
-
-    return { page, tags, revision };
-  }
-
-  async updatePage(pageId, revisionId, markdown, tmpParams) {
-    const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
-
-    // clone
-    const params = Object.assign(tmpParams, {
-      page_id: pageId,
-      revision_id: revisionId,
-      body: markdown,
-    });
-
-    const res = await apiPost('/pages.update', params);
-    if (!res.ok) {
-      throw new Error(res.error);
-    }
-    return res;
-  }
 
   showSuccessToastr() {
     toastr.success(undefined, 'Saved successfully', {

+ 88 - 1
packages/app/src/client/services/page-operation.ts

@@ -1,9 +1,15 @@
-import { SubscriptionStatusType } from '@growi/core';
+import { SubscriptionStatusType, Nullable } from '@growi/core';
 import urljoin from 'url-join';
 
+import { OptionsToSave } from '~/interfaces/editor-settings';
+import loggerFactory from '~/utils/logger';
+
+
 import { toastError } from '../util/apiNotification';
+import { apiPost } from '../util/apiv1-client';
 import { apiv3Post, apiv3Put } from '../util/apiv3-client';
 
+const logger = loggerFactory('growi:services:page-operation');
 
 export const toggleSubscribe = async(pageId: string, currentStatus: SubscriptionStatusType | undefined): Promise<void> => {
   try {
@@ -90,3 +96,84 @@ export const exportAsMarkdown = (pageId: string, revisionId: string, format: str
 export const resumeRenameOperation = async(pageId: string): Promise<void> => {
   await apiv3Post('/pages/resume-rename', { pageId });
 };
+
+
+export const createPage = async(pagePath: string, markdown: string, tmpParams: OptionsToSave) => {
+  // clone
+  const params = Object.assign(tmpParams, {
+    path: pagePath,
+    body: markdown,
+  });
+
+  const res = await apiv3Post('/pages/', params);
+  const { page, tags, revision } = res.data;
+
+  return { page, tags, revision };
+};
+
+export const updatePage = async(pageId: string, revisionId: string, markdown: string, tmpParams: OptionsToSave) => {
+  // clone
+  const params = Object.assign(tmpParams, {
+    page_id: pageId,
+    revision_id: revisionId,
+    body: markdown,
+  });
+
+  const res: any = await apiPost('/pages.update', params);
+  if (!res.ok) {
+    throw new Error(res.error);
+  }
+  return res;
+};
+
+type PageInfo= {
+  path: string,
+  pageId: Nullable<string>,
+  revisionId: Nullable<string>,
+}
+
+
+export const saveAndReload = async(optionsToSave: OptionsToSave, pageInfo: PageInfo, markdown: string) => {
+  const { path, pageId, revisionId } = pageInfo;
+
+  const options = Object.assign({}, optionsToSave);
+
+  /*
+  * Note: variable "markdown" will be received from params
+  * please delete the following code after implemating HackMD editor function
+  */
+  // let markdown;
+  // if (editorMode === EditorMode.HackMD) {
+  // const pageEditorByHackmd = this.appContainer.getComponentInstance('PageEditorByHackmd');
+  // markdown = await pageEditorByHackmd.getMarkdown();
+  // // set option to sync
+  // options.isSyncRevisionToHackmd = true;
+  // revisionId = this.state.revisionIdHackmdSynced;
+  // }
+  // else {
+  // const pageEditor = this.appContainer.getComponentInstance('PageEditor');
+  // const pageEditor = getComponentInstance('PageEditor');
+  // markdown = pageEditor.getMarkdown();
+  // }
+
+  let res;
+  if (pageId == null) {
+    res = await createPage(path, markdown, options);
+  }
+  else {
+    if (revisionId == null) {
+      const msg = '\'revisionId\' is required to update page';
+      throw new Error(msg);
+    }
+    res = await updatePage(pageId, revisionId, markdown, options);
+  }
+
+  /*
+  * TODO: implement Draft function => https://redmine.weseek.co.jp/issues/103246
+  */
+  // const editorContainer = this.appContainer.getContainer('EditorContainer');
+  // editorContainer.clearDraft(path);
+  window.location.href = path;
+
+  return res;
+};

+ 1 - 8
packages/app/src/client/util/editor.ts

@@ -1,11 +1,4 @@
-type OptionsToSave = {
-  isSlackEnabled: boolean;
-  slackChannels: string;
-  grant: number;
-  pageTags: string[] | null;
-  grantUserGroupId?: string | null;
-  grantUserGroupName?: string | null;
-};
+import { OptionsToSave } from '~/interfaces/editor-settings';
 
 export const getOptionsToSave = (
     isSlackEnabled: boolean,

+ 58 - 17
packages/app/src/components/PageEditor.tsx

@@ -8,13 +8,14 @@ import { envUtils } from '@growi/core';
 import detectIndent from 'detect-indent';
 import { throttle, debounce } from 'throttle-debounce';
 
-// import AppContainer from '~/client/services/AppContainer';
+import { saveAndReload } from '~/client/services/page-operation';
+
 // import EditorContainer from '~/client/services/EditorContainer';
 // import PageContainer from '~/client/services/PageContainer';
 import { apiGet, apiPostForm } from '~/client/util/apiv1-client';
 import { getOptionsToSave } from '~/client/util/editor';
 import {
-  useIsEditable, useIsIndentSizeForced, useCurrentPagePath, useCurrentPageId, useIsUploadableFile, useIsUploadableImage,
+  useIsEditable, useIsIndentSizeForced, useCurrentPagePath, useCurrentPathname, useCurrentPageId, useIsUploadableFile, useIsUploadableImage,
 } from '~/stores/context';
 import {
   useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
@@ -53,7 +54,6 @@ type EditorRef = {
 }
 
 type Props = {
-  // appContainer: AppContainer,
   // pageContainer: PageContainer,
   // editorContainer: EditorContainer,
 
@@ -82,7 +82,7 @@ let isOriginOfScrollSyncPreview = false;
 
 const PageEditor = (props: Props): JSX.Element => {
   // const {
-  //   appContainer, pageContainer, editorContainer,
+  //   pageContainer, editorContainer,
   // } = props;
 
   const { data: isEditable } = useIsEditable();
@@ -92,6 +92,7 @@ const PageEditor = (props: Props): JSX.Element => {
   const { data: pageId } = useCurrentPageId();
   const { data: pageTags } = usePageTagsForEditors(pageId);
   const { data: currentPagePath } = useCurrentPagePath();
+  const { data: currentPathname } = useCurrentPathname();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
   const { data: isTextlintEnabled } = useIsTextlintEnabled();
@@ -106,12 +107,15 @@ const PageEditor = (props: Props): JSX.Element => {
 
   const [markdown, setMarkdown] = useState<string>('');
 
+
   useEffect(() => {
     if (currentPage != null) {
       setMarkdown(currentPage.revision?.body);
     }
   }, [currentPage, currentPage?.revision?.body]);
 
+  const slackChannels = useMemo(() => (slackChannelsData ? slackChannelsData.toString() : ''), []);
+
 
   const editorRef = useRef<EditorRef>(null);
   const previewRef = useRef<HTMLDivElement>(null);
@@ -136,8 +140,6 @@ const PageEditor = (props: Props): JSX.Element => {
       return;
     }
 
-    const slackChannels = slackChannelsData ? slackChannelsData.toString() : '';
-
     const optionsToSave = getOptionsToSave(
       isSlackEnabled ?? false, slackChannels,
       grantData.grant, grantData.grantedGroup?.id, grantData.grantedGroup?.name,
@@ -316,16 +318,6 @@ const PageEditor = (props: Props): JSX.Element => {
   const scrollEditorByPreviewScrollWithThrottle = useMemo(() => throttle(20, scrollEditorByPreviewScroll), [scrollEditorByPreviewScroll]);
 
 
-  // register dummy instance to get markdown
-  // useEffect(() => {
-  //   const pageEditorInstance = {
-  //     getMarkdown: () => {
-  //       return markdown;
-  //     },
-  //   };
-  //   appContainer.registerComponentInstance('PageEditor', pageEditorInstance);
-  // }, [appContainer, markdown]);
-
   // initial caret line
   useEffect(() => {
     if (editorRef.current != null) {
@@ -350,6 +342,55 @@ const PageEditor = (props: Props): JSX.Element => {
     };
   }, []);
 
+
+  const saveAndReloadHandler = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}) => {
+    if (editorMode !== EditorMode.Editor) {
+      return;
+    }
+
+    const grant = grantData?.grant || 1;
+    const grantedGroup = grantData?.grantedGroup;
+
+    if (isSlackEnabled == null || currentPathname == null) {
+      return;
+    }
+
+    let optionsToSave;
+
+    const currentOptionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant || 1, grantedGroup?.id, grantedGroup?.name, pageTags || []);
+
+    if (opts != null) {
+      optionsToSave = Object.assign(currentOptionsToSave, {
+        ...opts,
+      });
+    }
+    else {
+      optionsToSave = currentOptionsToSave;
+    }
+
+    await saveAndReload(optionsToSave, { pageId, path: currentPagePath || currentPathname, revisionId: currentPage?.revision?._id }, markdown);
+  }, [currentPage?.revision?._id,
+      currentPagePath,
+      currentPathname,
+      editorMode,
+      grantData?.grant,
+      grantData?.grantedGroup,
+      isSlackEnabled,
+      markdown,
+      pageId,
+      pageTags,
+      slackChannels,
+  ]);
+
+  // set handler to save and reload Page
+  useEffect(() => {
+    globalEmitter.on('saveAndReload', saveAndReloadHandler);
+
+    return function cleanup() {
+      globalEmitter.removeListener('saveAndReload', saveAndReloadHandler);
+    };
+  }, [saveAndReloadHandler]);
+
   // set handler to focus
   useEffect(() => {
     if (editorRef.current != null && editorMode === EditorMode.Editor) {
@@ -441,7 +482,7 @@ const PageEditor = (props: Props): JSX.Element => {
 /**
    * Wrapper component for using unstated
    */
-// const PageEditorWrapper = withUnstatedContainers(PageEditor, [AppContainer, PageContainer, EditorContainer]);
+// const PageEditorWrapper = withUnstatedContainers(PageEditor, [PageContainer, EditorContainer]);
 
 // export default PageEditorWrapper;
 export default PageEditor;

+ 9 - 10
packages/app/src/components/PageEditor/EditorNavbarBottom.tsx

@@ -1,5 +1,6 @@
 import React, { useCallback, useState, useEffect } from 'react';
 
+import dynamic from 'next/dynamic';
 import { Collapse, Button } from 'reactstrap';
 
 
@@ -9,29 +10,27 @@ import {
   EditorMode, useDrawerOpened, useEditorMode, useIsDeviceSmallerThanMd,
 } from '~/stores/ui';
 
-import SavePageControls from '../SavePageControls';
-import SlackLogo from '../SlackLogo';
-import { SlackNotification } from '../SlackNotification';
 
+const SavePageControls = dynamic(() => import('~/components/SavePageControls').then(mod => mod.SavePageControls), { ssr: false });
+const SlackLogo = dynamic(() => import('~/components/SlackLogo').then(mod => mod.SlackLogo), { ssr: false });
+const SlackNotification = dynamic(() => import('~/components/SlackNotification').then(mod => mod.SlackNotification), { ssr: false });
+const OptionsSelector = dynamic(() => import('~/components/PageEditor/OptionsSelector').then(mod => mod.OptionsSelector), { ssr: false });
 
-import OptionsSelector from './OptionsSelector';
 
-const EditorNavbarBottom = (props) => {
-
-  const { data: editorMode } = useEditorMode();
+const EditorNavbarBottom = (): JSX.Element => {
 
   const [isExpanded, setExpanded] = useState(false);
-
   const [isSlackExpanded, setSlackExpanded] = useState(false);
 
+  const { data: editorMode } = useEditorMode();
   const { data: isSlackConfigured } = useIsSlackConfigured();
   const { mutate: mutateDrawerOpened } = useDrawerOpened();
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
-  const additionalClasses = ['grw-editor-navbar-bottom'];
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
 
   const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
+  const additionalClasses = ['grw-editor-navbar-bottom'];
 
   const [slackChannelsStr, setSlackChannelsStr] = useState<string>('');
 
@@ -129,7 +128,7 @@ const EditorNavbarBottom = (props) => {
               )}
             </div>
           ))}
-          <SavePageControls slackChannels={slackChannelsStr} isSlackEnabled={isSlackEnabled || false} />
+          <SavePageControls />
           { isCollapsedOptionsSelectorEnabled && renderExpandButton() }
         </form>
       </div>

+ 1 - 4
packages/app/src/components/PageEditor/OptionsSelector.tsx

@@ -340,7 +340,7 @@ const ConfigurationDropdown = memo(({ onConfirmEnableTextlint }: ConfigurationDr
 ConfigurationDropdown.displayName = 'ConfigurationDropdown';
 
 
-const OptionsSelector = (): JSX.Element => {
+export const OptionsSelector = (): JSX.Element => {
   const [isDownloadDictModalShown, setDownloadDictModalShown] = useState(false);
 
   const { data: editorSettings, turnOffAskingBeforeDownloadLargeFiles } = useEditorSettings();
@@ -394,6 +394,3 @@ const OptionsSelector = (): JSX.Element => {
   );
 
 };
-
-
-export default OptionsSelector;

+ 1 - 1
packages/app/src/components/PageEditorByHackmd.tsx

@@ -69,7 +69,7 @@ const PageEditorByHackmd = (props: PageEditorByHackmdProps) => {
         setIsInitialized(false);
       },
     };
-    appContainer.registerComponentInstance('PageEditorByHackmd', pageEditorByHackmdInstance);
+    // appContainer.registerComponentInstance('PageEditorByHackmd', pageEditorByHackmdInstance);
   }, [appContainer, isInitialized, t]);
 
   const getHackmdUri = useCallback(() => {

+ 0 - 194
packages/app/src/components/SavePageControls.jsx

@@ -1,194 +0,0 @@
-import React from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-import {
-  UncontrolledButtonDropdown, Button,
-  DropdownToggle, DropdownMenu, DropdownItem,
-} from 'reactstrap';
-
-// import PageContainer from '~/client/services/PageContainer';
-import { getOptionsToSave } from '~/client/util/editor';
-import { useIsEditable, useCurrentPageId, useIsAclEnabled } from '~/stores/context';
-import { usePageTagsForEditors, useIsEnabledUnsavedWarning } from '~/stores/editor';
-import {
-  useEditorMode, useSelectedGrant,
-} from '~/stores/ui';
-import loggerFactory from '~/utils/logger';
-
-import GrantSelector from './SavePageControls/GrantSelector';
-import { withUnstatedContainers } from './UnstatedUtils';
-
-const logger = loggerFactory('growi:SavePageControls');
-
-class SavePageControls extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.updateGrantHandler = this.updateGrantHandler.bind(this);
-
-    this.save = this.save.bind(this);
-    this.saveAndOverwriteScopesOfDescendants = this.saveAndOverwriteScopesOfDescendants.bind(this);
-
-  }
-
-  updateGrantHandler(data) {
-    const { mutateGrant, mutateGrantGroupId, mutateGrantGroupName } = this.props;
-
-    mutateGrant(data.grant);
-    mutateGrantGroupId(data.grantGroupId);
-    mutateGrantGroupName(data.grantGroupName);
-  }
-
-  async save() {
-    const {
-      isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, /* pageContainer, */ pageTags, mutateIsEnabledUnsavedWarning,
-    } = this.props;
-    // disable unsaved warning
-    mutateIsEnabledUnsavedWarning(false);
-
-    try {
-      // save
-      const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
-      // await pageContainer.saveAndReload(optionsToSave, this.props.editorMode);
-    }
-    catch (error) {
-      logger.error('failed to save', error);
-      // pageContainer.showErrorToastr(error);
-      if (error.code === 'conflict') {
-        // pageContainer.setState({
-        //   remoteRevisionId: error.data.revisionId,
-        //   remoteRevisionBody: error.data.revisionBody,
-        //   remoteRevisionUpdateAt: error.data.createdAt,
-        //   lastUpdateUser: error.data.user,
-        // });
-      }
-    }
-  }
-
-  saveAndOverwriteScopesOfDescendants() {
-    const {
-      isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, /* pageContainer, */ pageTags, mutateIsEnabledUnsavedWarning,
-    } = this.props;
-    // disable unsaved warning
-    mutateIsEnabledUnsavedWarning(false);
-    // save
-    const currentOptionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
-    const optionsToSave = Object.assign(currentOptionsToSave, {
-      overwriteScopesOfDescendants: true,
-    });
-    // pageContainer.saveAndReload(optionsToSave, this.props.editorMode);
-  }
-
-  render() {
-
-    const {
-      t, /* pageContainer, */ isAclEnabled, grant, grantGroupId, grantGroupName,
-    } = this.props;
-
-    // const isRootPage = pageContainer.state.path === '/';
-    // const labelSubmitButton = pageContainer.state.pageId == null ? t('Create') : t('Update');
-    // const labelOverwriteScopes = t('page_edit.overwrite_scopes', { operation: labelSubmitButton });
-
-    return (
-      <div className="d-flex align-items-center form-inline flex-nowrap">
-
-        {isAclEnabled
-          && (
-            <div className="mr-2">
-              <GrantSelector
-                // disabled={isRootPage}
-                grant={grant}
-                grantGroupId={grantGroupId}
-                grantGroupName={grantGroupName}
-                onUpdateGrant={this.updateGrantHandler}
-              />
-            </div>
-          )
-        }
-
-        <UncontrolledButtonDropdown direction="up">
-          <Button id="caret" color="primary" className="btn-submit" onClick={this.save}>
-          labelSubmitButton
-            {/* {labelSubmitButton} */}
-          </Button>
-          <DropdownToggle caret color="primary" />
-          <DropdownMenu right>
-            <DropdownItem onClick={this.saveAndOverwriteScopesOfDescendants}>
-            labelOverwriteScopes
-              {/* {labelOverwriteScopes} */}
-            </DropdownItem>
-          </DropdownMenu>
-        </UncontrolledButtonDropdown>
-
-      </div>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-// const SavePageControlsHOCWrapper = withUnstatedContainers(SavePageControls, [PageContainer]);
-
-const SavePageControlsWrapper = (props) => {
-  const { t } = useTranslation();
-  const { data: isEditable } = useIsEditable();
-  const { data: editorMode } = useEditorMode();
-  const { data: isAclEnabled } = useIsAclEnabled();
-  const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
-  const { data: pageId } = useCurrentPageId();
-  const { data: pageTags } = usePageTagsForEditors(pageId);
-  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
-
-
-  if (isEditable == null || editorMode == null || isAclEnabled == null || grantData == null) {
-    return null;
-  }
-
-  if (!isEditable) {
-    return null;
-  }
-
-  return (
-    // <SavePageControlsHOCWrapper
-    <SavePageControls
-      t={t}
-      {...props}
-      editorMode={editorMode}
-      isAclEnabled={isAclEnabled}
-      grant={grantData.grant}
-      grantGroupId={grantData.grantGroup?.id}
-      grantGroupName={grantData.grantedGroup?.name}
-      mutateGrant={mutateGrant}
-      // mutateGrantGroupId={mutateGrantGroupId}
-      // mutateGrantGroupName={mutateGrantGroupName}
-      mutateIsEnabledUnsavedWarning={mutateIsEnabledUnsavedWarning}
-      pageTags={pageTags}
-    />
-  );
-};
-
-SavePageControls.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-
-  // pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
-  // TODO: remove this when omitting unstated is completed
-  editorMode: PropTypes.string.isRequired,
-  isSlackEnabled: PropTypes.bool.isRequired,
-  slackChannels: PropTypes.string.isRequired,
-  pageTags: PropTypes.arrayOf(PropTypes.string),
-  isAclEnabled: PropTypes.bool.isRequired,
-  grant: PropTypes.number.isRequired,
-  grantGroupId: PropTypes.string,
-  grantGroupName: PropTypes.string,
-  mutateGrant: PropTypes.func,
-  mutateGrantGroupId: PropTypes.func,
-  mutateGrantGroupName: PropTypes.func,
-  mutateIsEnabledUnsavedWarning: PropTypes.func,
-};
-
-export default SavePageControlsWrapper;

+ 124 - 0
packages/app/src/components/SavePageControls.tsx

@@ -0,0 +1,124 @@
+import React, { useCallback } from 'react';
+
+import { pagePathUtils } from '@growi/core';
+import { useTranslation } from 'next-i18next';
+import {
+  UncontrolledButtonDropdown, Button,
+  DropdownToggle, DropdownMenu, DropdownItem,
+} from 'reactstrap';
+
+// import PageContainer from '~/client/services/PageContainer';
+import { CustomWindow } from '~/interfaces/global';
+import { IPageGrantData } from '~/interfaces/page';
+import {
+  useCurrentPagePath, useIsEditable, useCurrentPageId, useIsAclEnabled,
+} from '~/stores/context';
+import { useIsEnabledUnsavedWarning } from '~/stores/editor';
+import { useSelectedGrant } from '~/stores/ui';
+import loggerFactory from '~/utils/logger';
+
+import GrantSelector from './SavePageControls/GrantSelector';
+
+// import { withUnstatedContainers } from './UnstatedUtils';
+
+const logger = loggerFactory('growi:SavePageControls');
+
+type Props = {
+  // pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+}
+
+const { isTopPage } = pagePathUtils;
+
+export const SavePageControls = (props: Props): JSX.Element | null => {
+  const { t } = useTranslation();
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { data: isEditable } = useIsEditable();
+  const { data: isAclEnabled } = useIsAclEnabled();
+  const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
+  const { data: pageId } = useCurrentPageId();
+  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
+
+
+  const updateGrantHandler = useCallback((grantData: IPageGrantData): void => {
+    mutateGrant(grantData);
+  }, [mutateGrant]);
+
+  const save = useCallback(async(): Promise<void> => {
+    // disable unsaved warning
+    mutateIsEnabledUnsavedWarning(false);
+
+    try {
+      // save
+      (window as CustomWindow).globalEmitter.emit('saveAndReload');
+    }
+    catch (error) {
+      logger.error('failed to save', error);
+      // pageContainer.showErrorToastr(error);
+      if (error.code === 'conflict') {
+        // pageContainer.setState({
+        //   remoteRevisionId: error.data.revisionId,
+        //   remoteRevisionBody: error.data.revisionBody,
+        //   remoteRevisionUpdateAt: error.data.createdAt,
+        //   lastUpdateUser: error.data.user,
+        // });
+      }
+    }
+  }, [mutateIsEnabledUnsavedWarning]);
+
+  const saveAndOverwriteScopesOfDescendants = useCallback(() => {
+    // disable unsaved warning
+    mutateIsEnabledUnsavedWarning(false);
+    // save
+    (window as CustomWindow).globalEmitter.emit('saveAndReload', { overwriteScopesOfDescendants: true });
+  }, [mutateIsEnabledUnsavedWarning]);
+
+
+  if (isEditable == null || isAclEnabled == null) {
+    return null;
+  }
+
+  if (!isEditable) {
+    return null;
+  }
+
+  const grant = grantData?.grant || 1;
+  const grantedGroup = grantData?.grantedGroup;
+
+  // const {  pageContainer } = props;
+
+  const isRootPage = isTopPage(currentPagePath ?? '');
+  const labelSubmitButton = pageId == null ? t('Create') : t('Update');
+  const labelOverwriteScopes = t('page_edit.overwrite_scopes', { operation: labelSubmitButton });
+
+  return (
+    <div className="d-flex align-items-center form-inline flex-nowrap">
+
+      {isAclEnabled
+          && (
+            <div className="mr-2">
+              <GrantSelector
+                grant={grant}
+                disabled={isRootPage}
+                grantGroupId={grantedGroup?.id}
+                grantGroupName={grantedGroup?.name}
+                onUpdateGrant={updateGrantHandler}
+              />
+            </div>
+          )
+      }
+
+      <UncontrolledButtonDropdown direction="up">
+        <Button id="caret" color="primary" className="btn-submit" onClick={save}>
+          {labelSubmitButton}
+        </Button>
+        <DropdownToggle caret color="primary" />
+        <DropdownMenu right>
+          <DropdownItem onClick={saveAndOverwriteScopesOfDescendants}>
+            {labelOverwriteScopes}
+          </DropdownItem>
+        </DropdownMenu>
+      </UncontrolledButtonDropdown>
+
+    </div>
+  );
+};

+ 3 - 2
packages/app/src/components/SavePageControls/GrantSelector.tsx

@@ -50,6 +50,8 @@ const GrantSelector = (props: Props): JSX.Element => {
     disabled,
     grantGroupName,
     onUpdateGrant,
+    grant: currentGrant,
+    grantGroupId,
   } = props;
 
 
@@ -93,7 +95,6 @@ const GrantSelector = (props: Props): JSX.Element => {
    * Render grant selector DOM.
    */
   const renderGrantSelector = useCallback(() => {
-    const { grant: currentGrant, grantGroupId } = props;
 
     let dropdownToggleBtnColor;
     let dropdownToggleLabelElm;
@@ -146,7 +147,7 @@ const GrantSelector = (props: Props): JSX.Element => {
         </UncontrolledDropdown>
       </div>
     );
-  }, [changeGrantHandler, disabled, grantGroupName, props, t]);
+  }, [changeGrantHandler, currentGrant, disabled, grantGroupId, grantGroupName, t]);
 
   /**
    * Render select grantgroup modal.

+ 1 - 3
packages/app/src/components/SlackLogo.jsx

@@ -1,6 +1,6 @@
 import React from 'react';
 
-const SlackLogo = () => (
+export const SlackLogo = () => (
   <svg
     xmlns="http://www.w3.org/2000/svg"
     viewBox="0 0 448 448"
@@ -17,5 +17,3 @@ const SlackLogo = () => (
     />
   </svg>
 );
-
-export default SlackLogo;

+ 9 - 0
packages/app/src/interfaces/editor-settings.ts

@@ -36,3 +36,12 @@ export type EditorConfig = {
     isUploadableImage: boolean,
   }
 }
+
+export type OptionsToSave = {
+  isSlackEnabled: boolean;
+  slackChannels: string;
+  grant: number;
+  pageTags: string[] | null;
+  grantUserGroupId?: string | null;
+  grantUserGroupName?: string | null;
+};

+ 3 - 2
packages/app/src/stores/page.tsx

@@ -2,18 +2,19 @@ import { IPageInfoForEntity, IPagePopulatedToShowRevision, Nullable } from '@gro
 import useSWR, { SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
+import { apiGet } from '~/client/util/apiv1-client';
 import { apiv3Get } from '~/client/util/apiv3-client';
 import {
-  IPageInfo, IPageInfoForOperation, IPageInfoAll,
+  IPageInfo, IPageInfoForOperation,
 } from '~/interfaces/page';
 import { IRecordApplicableGrant, IResIsGrantNormalized } from '~/interfaces/page-grant';
 import { IRevisionsForPagination } from '~/interfaces/revision';
 
-import { apiGet } from '../client/util/apiv1-client';
 import { IPageTagsInfo } from '../interfaces/tag';
 
 import { useCurrentPageId } from './context';
 
+
 export const useSWRxPage = (pageId?: string|null, shareLinkId?: string): SWRResponse<IPagePopulatedToShowRevision|null, Error> => {
   return useSWR<IPagePopulatedToShowRevision|null, Error>(
     pageId != null ? ['/page', pageId, shareLinkId] : null,