Jelajahi Sumber

Merge pull request #1004 from weseek/imprv/refactor-with-unstated

Imprv/refactor with unstated
Yuki Takei 6 tahun lalu
induk
melakukan
bfed01673e

+ 4 - 156
src/client/js/app.js

@@ -4,7 +4,6 @@ import React from 'react';
 import ReactDOM from 'react-dom';
 import { Provider } from 'unstated';
 import { I18nextProvider } from 'react-i18next';
-import * as toastr from 'toastr';
 
 import loggerFactory from '@alias/logger';
 import Xss from '@commons/service/xss';
@@ -75,157 +74,6 @@ appContainer.injectToWindow();
 
 const i18n = appContainer.i18n;
 
-/**
- * save success handler when reloading is not needed
- * @param {object} page Page instance
- */
-const saveWithShortcutSuccessHandler = function(result) {
-  const { page, tags } = result;
-  const { editorMode } = appContainer.state;
-
-  // show toastr
-  toastr.success(undefined, 'Saved successfully', {
-    closeButton: true,
-    progressBar: true,
-    newestOnTop: false,
-    showDuration: '100',
-    hideDuration: '100',
-    timeOut: '1200',
-    extendedTimeOut: '150',
-  });
-
-  // update state of PageContainer
-  const newState = {
-    pageId: page._id,
-    revisionId: page.revision._id,
-    revisionCreatedAt: new Date(page.revision.createdAt).getTime() / 1000,
-    remoteRevisionId: page.revision._id,
-    revisionIdHackmdSynced: page.revisionHackmdSynced,
-    hasDraftOnHackmd: page.hasDraftOnHackmd,
-    markdown: page.revision.body,
-    tags,
-  };
-  pageContainer.setState(newState);
-
-  // update state of EditorContainer
-  editorContainer.setState({ tags });
-
-  // PageEditor component
-  const pageEditor = appContainer.getComponentInstance('PageEditor');
-  if (pageEditor != null) {
-    if (editorMode !== 'builtin') {
-      pageEditor.updateEditorValue(newState.markdown);
-    }
-  }
-  // PageEditorByHackmd component
-  const pageEditorByHackmd = appContainer.getComponentInstance('PageEditorByHackmd');
-  if (pageEditorByHackmd != null) {
-    // reset
-    if (editorMode !== 'hackmd') {
-      pageEditorByHackmd.reset();
-    }
-  }
-
-  // hidden input
-  $('input[name="revision_id"]').val(newState.revisionId);
-};
-
-const errorHandler = function(error) {
-  toastr.error(error.message, 'Error occured', {
-    closeButton: true,
-    progressBar: true,
-    newestOnTop: false,
-    showDuration: '100',
-    hideDuration: '100',
-    timeOut: '3000',
-  });
-};
-
-const saveWithShortcut = function(markdown) {
-  const { editorMode } = appContainer.state;
-
-  const { pageId, path } = pageContainer.state;
-  let { revisionId } = pageContainer.state;
-
-  // get options
-  const options = editorContainer.getCurrentOptionsToSave();
-  options.socketClientId = websocketContainer.getCocketClientId();
-  options.pageTags = editorContainer.state.tags;
-
-  if (editorMode === 'hackmd') {
-    // set option to sync
-    options.isSyncRevisionToHackmd = true;
-    revisionId = pageContainer.state.revisionIdHackmdSynced;
-  }
-
-  let promise;
-  if (pageId == null) {
-    promise = appContainer.createPage(path, markdown, options);
-  }
-  else {
-    promise = appContainer.updatePage(pageId, revisionId, markdown, options);
-  }
-
-  promise
-    .then(saveWithShortcutSuccessHandler)
-    .catch(errorHandler);
-};
-
-const saveWithSubmitButtonSuccessHandler = function() {
-  const { path } = pageContainer.state;
-  editorContainer.clearDraft(path);
-  window.location.href = path;
-};
-
-const saveWithSubmitButton = function(submitOpts) {
-  const { editorMode } = appContainer.state;
-  if (editorMode == null) {
-    // do nothing
-    return;
-  }
-
-  const { pageId, path } = pageContainer.state;
-  let { revisionId } = pageContainer.state;
-  // get options
-  const options = editorContainer.getCurrentOptionsToSave();
-  options.socketClientId = websocketContainer.getSocketClientId();
-  options.pageTags = editorContainer.state.tags;
-
-  // set 'submitOpts.overwriteScopesOfDescendants' to options
-  options.overwriteScopesOfDescendants = submitOpts ? !!submitOpts.overwriteScopesOfDescendants : false;
-
-  let promise;
-  if (editorMode === 'hackmd') {
-    const pageEditorByHackmd = appContainer.getComponentInstance('PageEditorByHackmd');
-    // get markdown
-    promise = pageEditorByHackmd.getMarkdown();
-    // use revisionId of PageEditorByHackmd
-    revisionId = pageContainer.state.revisionIdHackmdSynced;
-    // set option to sync
-    options.isSyncRevisionToHackmd = true;
-  }
-  else {
-    const pageEditor = appContainer.getComponentInstance('PageEditor');
-    // get markdown
-    promise = Promise.resolve(pageEditor.getMarkdown());
-  }
-  // create or update
-  if (pageId == null) {
-    promise = promise.then((markdown) => {
-      return appContainer.createPage(path, markdown, options);
-    });
-  }
-  else {
-    promise = promise.then((markdown) => {
-      return appContainer.updatePage(pageId, revisionId, markdown, options);
-    });
-  }
-
-  promise
-    .then(saveWithSubmitButtonSuccessHandler)
-    .catch(errorHandler);
-};
-
 /**
  * define components
  *  key: id of element
@@ -241,10 +89,10 @@ let componentMappings = {
 
   'create-page-name-input': <PagePathAutoComplete crowi={appContainer} initializedPath={pageContainer.state.path} addTrailingSlash />,
 
-  'page-editor': <PageEditor onSaveWithShortcut={saveWithShortcut} />,
+  'page-editor': <PageEditor />,
   'page-editor-options-selector': <OptionsSelector crowi={appContainer} />,
   'page-status-alert': <PageStatusAlert />,
-  'save-page-controls': <SavePageControls onSubmit={saveWithSubmitButton} />,
+  'save-page-controls': <SavePageControls />,
 
   'user-created-list': <RecentCreated />,
   'user-draft-list': <MyDraftList />,
@@ -253,7 +101,7 @@ let componentMappings = {
 // additional definitions if data exists
 if (pageContainer.state.pageId != null) {
   componentMappings = Object.assign({
-    'page-editor-with-hackmd': <PageEditorByHackmd onSaveWithShortcut={saveWithShortcut} />,
+    'page-editor-with-hackmd': <PageEditorByHackmd />,
     'page-comments-list': <PageComments />,
     'page-attachment':  <PageAttachment />,
     'page-comment-write':  <CommentEditorLazyRenderer />,
@@ -269,7 +117,7 @@ if (pageContainer.state.pageId != null) {
 if (pageContainer.state.path != null) {
   componentMappings = Object.assign({
     // eslint-disable-next-line quote-props
-    'page': <Page onSaveWithShortcut={saveWithShortcut} />,
+    'page': <Page />,
     'revision-path':  <RevisionPath pageId={pageContainer.state.pageId} pagePath={pageContainer.state.path} crowi={appContainer} />,
     'tag-label':  <TagLabels />,
   }, componentMappings);

+ 25 - 5
src/client/js/components/Page.jsx

@@ -1,5 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import loggerFactory from '@alias/logger';
 
 import { createSubscribedElement } from './UnstatedUtils';
 import AppContainer from '../services/AppContainer';
@@ -11,6 +12,8 @@ import RevisionRenderer from './Page/RevisionRenderer';
 import HandsontableModal from './PageEditor/HandsontableModal';
 import mtu from './PageEditor/MarkdownTableUtil';
 
+const logger = loggerFactory('growi:Page');
+
 class Page extends React.Component {
 
   constructor(props) {
@@ -25,6 +28,10 @@ class Page extends React.Component {
     this.saveHandlerForHandsontableModal = this.saveHandlerForHandsontableModal.bind(this);
   }
 
+  componentWillMount() {
+    this.props.appContainer.registerComponentInstance(this);
+  }
+
   /**
    * launch HandsontableModal with data specified by arguments
    * @param beginLineNumber
@@ -37,15 +44,30 @@ class Page extends React.Component {
     this.handsontableModal.show(MarkdownTable.fromMarkdownString(tableLines));
   }
 
-  saveHandlerForHandsontableModal(markdownTable) {
+  async saveHandlerForHandsontableModal(markdownTable) {
+    const { pageContainer } = this.props;
+
     const newMarkdown = mtu.replaceMarkdownTableInMarkdown(
       markdownTable,
       this.props.pageContainer.state.markdown,
       this.state.currentTargetTableArea.beginLineNumber,
       this.state.currentTargetTableArea.endLineNumber,
     );
-    this.props.onSaveWithShortcut(newMarkdown);
-    this.setState({ currentTargetTableArea: null });
+
+    try {
+      // eslint-disable-next-line no-unused-vars
+      const { page, tags } = await pageContainer.save(newMarkdown);
+      logger.debug('success to save');
+
+      pageContainer.showSuccessToastr();
+    }
+    catch (error) {
+      logger.error('failed to save', error);
+      pageContainer.showErrorToastr(error);
+    }
+    finally {
+      this.setState({ currentTargetTableArea: null });
+    }
   }
 
   render() {
@@ -73,8 +95,6 @@ const PageWrapper = (props) => {
 Page.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
-  onSaveWithShortcut: PropTypes.func.isRequired,
 };
 
 export default PageWrapper;

+ 33 - 27
src/client/js/components/PageEditor.jsx

@@ -1,10 +1,9 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import loggerFactory from '@alias/logger';
 
 import { throttle, debounce } from 'throttle-debounce';
 
-import * as toastr from 'toastr';
-
 import AppContainer from '../services/AppContainer';
 import PageContainer from '../services/PageContainer';
 
@@ -14,6 +13,7 @@ import Preview from './PageEditor/Preview';
 import scrollSyncHelper from './PageEditor/ScrollSyncHelper';
 import EditorContainer from '../services/EditorContainer';
 
+const logger = loggerFactory('growi:PageEditor');
 
 class PageEditor extends React.Component {
 
@@ -35,14 +35,13 @@ class PageEditor extends React.Component {
     this.setCaretLine = this.setCaretLine.bind(this);
     this.focusToEditor = this.focusToEditor.bind(this);
     this.onMarkdownChanged = this.onMarkdownChanged.bind(this);
-    this.onSave = this.onSave.bind(this);
+    this.onSaveWithShortcut = this.onSaveWithShortcut.bind(this);
     this.onUpload = this.onUpload.bind(this);
     this.onEditorScroll = this.onEditorScroll.bind(this);
     this.onEditorScrollCursorIntoView = this.onEditorScrollCursorIntoView.bind(this);
     this.onPreviewScroll = this.onPreviewScroll.bind(this);
     this.saveDraft = this.saveDraft.bind(this);
     this.clearDraft = this.clearDraft.bind(this);
-    this.apiErrorHandler = this.apiErrorHandler.bind(this);
     this.showUnsavedWarning = this.showUnsavedWarning.bind(this);
 
     // get renderer
@@ -114,9 +113,27 @@ class PageEditor extends React.Component {
     this.props.appContainer.setIsDocSaved(false);
   }
 
-  onSave() {
-    this.props.onSaveWithShortcut(this.state.markdown);
-    this.props.appContainer.setIsDocSaved(true);
+  /**
+   * save and update state of containers
+   */
+  async onSaveWithShortcut() {
+    const { pageContainer, editorContainer } = this.props;
+    const optionsToSave = editorContainer.getCurrentOptionsToSave();
+
+    try {
+      // eslint-disable-next-line no-unused-vars
+      const { page, tags } = await pageContainer.save(this.state.markdown, optionsToSave);
+      logger.debug('success to save');
+
+      pageContainer.showSuccessToastr();
+
+      // update state of EditorContainer
+      editorContainer.setState({ tags });
+    }
+    catch (error) {
+      logger.error('failed to save', error);
+      pageContainer.showErrorToastr(error);
+    }
   }
 
   /**
@@ -124,9 +141,10 @@ class PageEditor extends React.Component {
    * @param {any} file
    */
   async onUpload(file) {
+    const { appContainer, pageContainer } = this.props;
+
     try {
-      let res = await this.props.appContainer.apiGet('/attachments.limit', {
-        _csrf: this.props.appContainer.csrfToken,
+      let res = await appContainer.apiGet('/attachments.limit', {
         fileSize: file.size,
       });
 
@@ -135,12 +153,12 @@ class PageEditor extends React.Component {
       }
 
       const formData = new FormData();
-      formData.append('_csrf', this.props.appContainer.csrfToken);
+      formData.append('_csrf', appContainer.csrfToken);
       formData.append('file', file);
-      formData.append('path', this.props.pageContainer.state.path);
+      formData.append('path', pageContainer.state.path);
       formData.append('page_id', this.state.pageId || 0);
 
-      res = await this.props.appContainer.apiPost('/attachments.add', formData);
+      res = await appContainer.apiPost('/attachments.add', formData);
       const attachment = res.attachment;
       const fileName = attachment.originalName;
 
@@ -158,7 +176,8 @@ class PageEditor extends React.Component {
       }
     }
     catch (e) {
-      this.apiErrorHandler(e);
+      logger.error('failed to upload', e);
+      pageContainer.showErrorToastr(e);
     }
     finally {
       this.editor.terminateUploadingState();
@@ -309,17 +328,6 @@ class PageEditor extends React.Component {
 
   }
 
-  apiErrorHandler(error) {
-    toastr.error(error.message, 'Error occured', {
-      closeButton: true,
-      progressBar: true,
-      newestOnTop: false,
-      showDuration: '100',
-      hideDuration: '100',
-      timeOut: '3000',
-    });
-  }
-
   render() {
     const config = this.props.appContainer.getConfig();
     const noCdn = !!config.env.NO_CDN;
@@ -340,7 +348,7 @@ class PageEditor extends React.Component {
             onScrollCursorIntoView={this.onEditorScrollCursorIntoView}
             onChange={this.onMarkdownChanged}
             onUpload={this.onUpload}
-            onSave={this.onSave}
+            onSave={this.onSaveWithShortcut}
           />
         </div>
         <div className="col-md-6 hidden-sm hidden-xs page-editor-preview-container">
@@ -370,8 +378,6 @@ PageEditor.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
-
-  onSaveWithShortcut: PropTypes.func.isRequired,
 };
 
 export default PageEditorWrapper;

+ 35 - 20
src/client/js/components/PageEditorByHackmd.jsx

@@ -1,17 +1,19 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import loggerFactory from '@alias/logger';
 
 import SplitButton from 'react-bootstrap/es/SplitButton';
 import MenuItem from 'react-bootstrap/es/MenuItem';
 
-import * as toastr from 'toastr';
-
 import AppContainer from '../services/AppContainer';
 import PageContainer from '../services/PageContainer';
+import EditorContainer from '../services/EditorContainer';
 
 import { createSubscribedElement } from './UnstatedUtils';
 import HackmdEditor from './PageEditorByHackmd/HackmdEditor';
 
+const logger = loggerFactory('growi:PageEditorByHackmd');
+
 class PageEditorByHackmd extends React.Component {
 
   constructor(props) {
@@ -26,9 +28,8 @@ class PageEditorByHackmd extends React.Component {
     this.getHackmdUri = this.getHackmdUri.bind(this);
     this.startToEdit = this.startToEdit.bind(this);
     this.resumeToEdit = this.resumeToEdit.bind(this);
+    this.onSaveWithShortcut = this.onSaveWithShortcut.bind(this);
     this.hackmdEditorChangeHandler = this.hackmdEditorChangeHandler.bind(this);
-
-    this.apiErrorHandler = this.apiErrorHandler.bind(this);
   }
 
   componentWillMount() {
@@ -97,7 +98,9 @@ class PageEditorByHackmd extends React.Component {
           revisionIdHackmdSynced: res.revisionIdHackmdSynced,
         });
       })
-      .catch(this.apiErrorHandler)
+      .catch((err) => {
+        pageContainer.showErrorToastr(err);
+      })
       .then(() => {
         this.setState({ isInitializing: false });
       });
@@ -117,6 +120,30 @@ class PageEditorByHackmd extends React.Component {
     this.props.pageContainer.setState({ hasDraftOnHackmd: false });
   }
 
+  /**
+   * save and update state of containers
+   * @param {string} markdown
+   */
+  async onSaveWithShortcut(markdown) {
+    const { pageContainer, editorContainer } = this.props;
+    const optionsToSave = editorContainer.getCurrentOptionsToSave();
+
+    try {
+      // eslint-disable-next-line no-unused-vars
+      const { page, tags } = await pageContainer.save(markdown, optionsToSave);
+      logger.debug('success to save');
+
+      pageContainer.showSuccessToastr();
+
+      // update state of EditorContainer
+      editorContainer.setState({ tags });
+    }
+    catch (error) {
+      logger.error('failed to save', error);
+      pageContainer.showErrorToastr(error);
+    }
+  }
+
   /**
    * onChange event of HackmdEditor handler
    */
@@ -146,17 +173,6 @@ class PageEditorByHackmd extends React.Component {
       });
   }
 
-  apiErrorHandler(error) {
-    toastr.error(error.message, 'Error occured', {
-      closeButton: true,
-      progressBar: true,
-      newestOnTop: false,
-      showDuration: '100',
-      hideDuration: '100',
-      timeOut: '3000',
-    });
-  }
-
   render() {
     const hackmdUri = this.getHackmdUri();
     const { pageContainer } = this.props;
@@ -176,7 +192,7 @@ class PageEditorByHackmd extends React.Component {
           initializationMarkdown={isResume ? null : this.state.markdown}
           onChange={this.hackmdEditorChangeHandler}
           onSaveWithShortcut={(document) => {
-            this.props.onSaveWithShortcut(document);
+            this.onSaveWithShortcut(document);
           }}
         >
         </HackmdEditor>
@@ -292,14 +308,13 @@ class PageEditorByHackmd extends React.Component {
  * Wrapper component for using unstated
  */
 const PageEditorByHackmdWrapper = (props) => {
-  return createSubscribedElement(PageEditorByHackmd, props, [AppContainer, PageContainer]);
+  return createSubscribedElement(PageEditorByHackmd, props, [AppContainer, PageContainer, EditorContainer]);
 };
 
 PageEditorByHackmd.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
-  onSaveWithShortcut: PropTypes.func.isRequired,
+  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 };
 
 export default PageEditorByHackmdWrapper;

+ 13 - 11
src/client/js/components/SavePageControls.jsx

@@ -29,8 +29,8 @@ class SavePageControls extends React.Component {
     this.slackChannelsChangedHandler = this.slackChannelsChangedHandler.bind(this);
     this.updateGrantHandler = this.updateGrantHandler.bind(this);
 
-    this.submit = this.submit.bind(this);
-    this.submitAndOverwriteScopesOfDescendants = this.submitAndOverwriteScopesOfDescendants.bind(this);
+    this.save = this.save.bind(this);
+    this.saveAndOverwriteScopesOfDescendants = this.saveAndOverwriteScopesOfDescendants.bind(this);
   }
 
   slackEnabledFlagChangedHandler(isSlackEnabled) {
@@ -45,13 +45,17 @@ class SavePageControls extends React.Component {
     this.props.editorContainer.setState(data);
   }
 
-  submit() {
-    this.props.appContainer.setIsDocSaved(true);
-    this.props.onSubmit();
+  save() {
+    const { pageContainer, editorContainer } = this.props;
+    pageContainer.saveAndReload(editorContainer.getCurrentOptionsToSave());
   }
 
-  submitAndOverwriteScopesOfDescendants() {
-    this.props.onSubmit({ overwriteScopesOfDescendants: true });
+  saveAndOverwriteScopesOfDescendants() {
+    const { pageContainer, editorContainer } = this.props;
+    const optionsToSave = Object.assign(editorContainer.getCurrentOptionsToSave(), {
+      overwriteScopesOfDescendants: true,
+    });
+    pageContainer.saveAndReload(optionsToSave);
   }
 
   render() {
@@ -94,10 +98,10 @@ class SavePageControls extends React.Component {
             className="btn-submit"
             dropup
             pullRight
-            onClick={this.submit}
+            onClick={this.save}
             title={labelSubmitButton}
           >
-            <MenuItem eventKey="1" onClick={this.submitAndOverwriteScopesOfDescendants}>{labelOverwriteScopes}</MenuItem>
+            <MenuItem eventKey="1" onClick={this.saveAndOverwriteScopesOfDescendants}>{labelOverwriteScopes}</MenuItem>
             {/* <MenuItem divider /> */}
           </SplitButton>
         </ButtonToolbar>
@@ -120,8 +124,6 @@ SavePageControls.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
-
-  onSubmit: PropTypes.func.isRequired,
 };
 
 export default withTranslation()(SavePageControlsWrapper);

+ 0 - 29
src/client/js/services/AppContainer.js

@@ -270,35 +270,6 @@ export default class AppContainer extends Container {
     return null;
   }
 
-  createPage(pagePath, markdown, additionalParams = {}) {
-    const params = Object.assign(additionalParams, {
-      path: pagePath,
-      body: markdown,
-    });
-    return this.apiPost('/pages.create', params)
-      .then((res) => {
-        if (!res.ok) {
-          throw new Error(res.error);
-        }
-        return { page: res.page, tags: res.tags };
-      });
-  }
-
-  updatePage(pageId, revisionId, markdown, additionalParams = {}) {
-    const params = Object.assign(additionalParams, {
-      page_id: pageId,
-      revision_id: revisionId,
-      body: markdown,
-    });
-    return this.apiPost('/pages.update', params)
-      .then((res) => {
-        if (!res.ok) {
-          throw new Error(res.error);
-        }
-        return { page: res.page, tags: res.tags };
-      });
-  }
-
   launchHandsontableModal(componentKind, beginLineNumber, endLineNumber) {
     let targetComponent;
     switch (componentKind) {

+ 1 - 0
src/client/js/services/EditorContainer.js

@@ -129,6 +129,7 @@ export default class EditorContainer extends Container {
       isSlackEnabled: this.state.isSlackEnabled,
       slackChannels: this.state.slackChannels,
       grant: this.state.grant,
+      pageTags: this.state.tags,
     };
 
     if (this.state.grantGroupId != null) {

+ 185 - 1
src/client/js/services/PageContainer.js

@@ -3,6 +3,7 @@ import { Container } from 'unstated';
 import loggerFactory from '@alias/logger';
 
 import * as entities from 'entities';
+import * as toastr from 'toastr';
 
 const logger = loggerFactory('growi:services:PageContainer');
 
@@ -40,7 +41,7 @@ export default class PageContainer extends Container {
       likerUserIds: [],
 
       tags: [],
-      templateTagData: mainContent.getAttribute('data-template-tags') || '',
+      templateTagData: mainContent.getAttribute('data-template-tags'),
 
       // latest(on remote) information
       remoteRevisionId: revisionId,
@@ -54,6 +55,7 @@ export default class PageContainer extends Container {
     this.initStateMarkdown();
     this.initStateOthers();
 
+    this.save = this.save.bind(this);
     this.addWebSocketEventHandlers = this.addWebSocketEventHandlers.bind(this);
     this.addWebSocketEventHandlers();
   }
@@ -101,6 +103,188 @@ export default class PageContainer extends Container {
     });
   }
 
+
+  /**
+   * save success handler
+   * @param {object} page Page instance
+   * @param {Array[Tag]} tags Array of Tag
+   */
+  updateStateAfterSave(page, tags) {
+    // mark that the document is not editing
+    this.appContainer.setIsDocSaved(true);
+
+    const { editorMode } = this.appContainer.state;
+
+    // update state of PageContainer
+    const newState = {
+      pageId: page._id,
+      revisionId: page.revision._id,
+      revisionCreatedAt: new Date(page.revision.createdAt).getTime() / 1000,
+      remoteRevisionId: page.revision._id,
+      revisionIdHackmdSynced: page.revisionHackmdSynced,
+      hasDraftOnHackmd: page.hasDraftOnHackmd,
+      markdown: page.revision.body,
+    };
+    if (tags != null) {
+      newState.tags = tags;
+    }
+    this.setState(newState);
+
+    // PageEditor component
+    const pageEditor = this.appContainer.getComponentInstance('PageEditor');
+    if (pageEditor != null) {
+      if (editorMode !== 'builtin') {
+        pageEditor.updateEditorValue(newState.markdown);
+      }
+    }
+    // PageEditorByHackmd component
+    const pageEditorByHackmd = this.appContainer.getComponentInstance('PageEditorByHackmd');
+    if (pageEditorByHackmd != null) {
+      // reset
+      if (editorMode !== 'hackmd') {
+        pageEditorByHackmd.reset();
+      }
+    }
+
+    // hidden input
+    $('input[name="revision_id"]').val(newState.revisionId);
+  }
+
+  /**
+   * Save page
+   * @param {string} markdown
+   * @param {object} optionsToSave
+   * @return {object} { page: Page, tags: Tag[] }
+   */
+  async save(markdown, optionsToSave = {}) {
+    const { editorMode } = this.appContainer.state;
+
+    const { pageId, path } = this.state;
+    let { revisionId } = this.state;
+
+    const options = Object.assign({}, optionsToSave);
+
+    if (editorMode === 'hackmd') {
+      // set option to sync
+      options.isSyncRevisionToHackmd = true;
+      revisionId = this.state.revisionIdHackmdSynced;
+    }
+
+    let res;
+    if (pageId == null) {
+      res = await this.createPage(path, markdown, options);
+    }
+    else {
+      res = await this.updatePage(pageId, revisionId, markdown, options);
+    }
+
+    this.updateStateAfterSave(res.page, res.tags);
+    return res;
+  }
+
+  async saveAndReload(optionsToSave) {
+    if (optionsToSave == null) {
+      const msg = '\'saveAndReload\' requires the \'optionsToSave\' param';
+      throw new Error(msg);
+    }
+
+    const { editorMode } = this.appContainer.state;
+    if (editorMode == null) {
+      logger.warn('\'saveAndReload\' requires the \'errorMode\' param');
+      return;
+    }
+
+    const { pageId, path } = this.state;
+    let { revisionId } = this.state;
+
+    const options = Object.assign({}, optionsToSave);
+
+    let markdown;
+    if (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 websocketContainer = this.appContainer.getContainer('WebsocketContainer');
+
+    // clone
+    const params = Object.assign(tmpParams, {
+      socketClientId: websocketContainer.getSocketClientId(),
+      path: pagePath,
+      body: markdown,
+    });
+
+    const res = await this.appContainer.apiPost('/pages.create', params);
+    if (!res.ok) {
+      throw new Error(res.error);
+    }
+    return { page: res.page, tags: res.tags };
+  }
+
+  async updatePage(pageId, revisionId, markdown, tmpParams) {
+    const websocketContainer = this.appContainer.getContainer('WebsocketContainer');
+
+    // clone
+    const params = Object.assign(tmpParams, {
+      socketClientId: websocketContainer.getSocketClientId(),
+      page_id: pageId,
+      revision_id: revisionId,
+      body: markdown,
+    });
+
+    const res = await this.appContainer.apiPost('/pages.update', params);
+    if (!res.ok) {
+      throw new Error(res.error);
+    }
+    return { page: res.page, tags: res.tags };
+  }
+
+  showSuccessToastr() {
+    toastr.success(undefined, 'Saved successfully', {
+      closeButton: true,
+      progressBar: true,
+      newestOnTop: false,
+      showDuration: '100',
+      hideDuration: '100',
+      timeOut: '1200',
+      extendedTimeOut: '150',
+    });
+  }
+
+  showErrorToastr(error) {
+    toastr.error(error.message, 'Error occured', {
+      closeButton: true,
+      progressBar: true,
+      newestOnTop: false,
+      showDuration: '100',
+      hideDuration: '100',
+      timeOut: '3000',
+    });
+  }
+
   addWebSocketEventHandlers() {
     const pageContainer = this;
     const websocketContainer = this.appContainer.getContainer('WebsocketContainer');

+ 1 - 1
src/client/js/services/TagContainer.js

@@ -34,7 +34,7 @@ export default class TagContainer extends Container {
 
     const { pageId, templateTagData } = pageContainer.state;
 
-    let tags;
+    let tags = [];
     // when the page exists
     if (pageId != null) {
       const res = await this.appContainer.apiGet('/pages.getPageTag', { pageId });

+ 3 - 1
src/server/views/widget/not_found_content.html

@@ -10,7 +10,9 @@
 <div id="content-main" class="content-main content-main-not-found page-list"
   data-path="{{ path | preventXss }}"
   data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
-  data-template-tags="{{ templateTags | '' }}"
+  {% if templateTags %}
+    data-template-tags="{{ templateTags }}"
+  {% endif %}
   >
 
   {% include 'not_found_tabs.html' %}