Просмотр исходного кода

Merge remote-tracking branch 'origin/support/reactify-pagemanagement-modal-for-merge' into support/refactor-page-path-auto-complete

itizawa 5 лет назад
Родитель
Сommit
8b84561051

+ 2 - 1
resource/locales/en-US/translation.json

@@ -285,7 +285,8 @@
     "completely": "Delete completely instead of putting it into trash."
   },
   "modal_empty":{
-    "empty_the_trash": "Empty The Trash"
+    "empty_the_trash": "Empty The Trash",
+    "notice": "The pages deleted completely are unrecoverable."
   },
   "modal_duplicate": {
     "label": {

+ 3 - 2
resource/locales/ja/translation.json

@@ -283,7 +283,8 @@
     "completely": "ゴミ箱を経由せず、完全に削除します"
   },
   "modal_empty":{
-    "empty_the_trash": "ゴミ箱を空にする"
+    "empty_the_trash": "ゴミ箱を空にする",
+    "notice": "完全削除したページは元に戻すことができません"
   },
   "modal_duplicate": {
     "label": {
@@ -330,7 +331,7 @@
     "activate_user_success": "{{username}}を有効化しました",
     "deactivate_user_success": "{{username}}を無効化しました",
     "remove_user_success": "{{username}}を削除しました",
-    "remove_external_user_success": "{{accountId}}を削除しました "
+    "remove_external_user_success": "{{accountId}}を削除しました"
   },
   "template": {
     "modal_label": {

+ 3 - 0
src/client/js/app.jsx

@@ -23,6 +23,7 @@ import PageTimeline from './components/PageTimeline';
 import CommentEditorLazyRenderer from './components/PageComment/CommentEditorLazyRenderer';
 import PageManagement from './components/Page/PageManagement';
 import PageDuplicateModal from './components/PageDuplicateModal';
+import TrashPageAlert from './components/Page/TrashPageAlert';
 import PageAttachment from './components/PageAttachment';
 import PageStatusAlert from './components/PageStatusAlert';
 import PagePathAutoComplete from './components/PagePathAutoComplete';
@@ -78,6 +79,8 @@ Object.assign(componentMappings, {
   'page-status-alert': <PageStatusAlert />,
   'save-page-controls': <SavePageControls />,
 
+  'trash-page-alert': <TrashPageAlert />,
+
   'page-timeline': <PageTimeline />,
 
   'personal-setting': <PersonalSettings crowi={personalContainer} />,

+ 42 - 0
src/client/js/components/EmptyTrashModal.jsx

@@ -0,0 +1,42 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+import { withTranslation } from 'react-i18next';
+
+const EmptyTrashModal = (props) => {
+  const {
+    t, isOpen, toggle, onClickSubmit,
+  } = props;
+
+  return (
+    <Modal isOpen={isOpen} toggle={toggle} className="grw-create-page">
+      <ModalHeader tag="h4" toggle={toggle} className="bg-danger text-light">
+        { t('modal_empty.empty_the_trash')}
+      </ModalHeader>
+      <ModalBody>
+        { t('modal_empty.notice')}
+      </ModalBody>
+      <ModalFooter>
+        {/* TODO add error message */}
+        <button type="button" className="btn btn-danger" onClick={onClickSubmit}>
+          <i className="icon-trash mr-2" aria-hidden="true"></i>Empty
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};
+
+
+EmptyTrashModal.propTypes = {
+  t: PropTypes.func.isRequired, //  i18next
+
+  isOpen: PropTypes.bool.isRequired,
+  toggle: PropTypes.func.isRequired,
+  onClickSubmit: PropTypes.func.isRequired,
+};
+
+export default withTranslation()(EmptyTrashModal);

+ 107 - 0
src/client/js/components/Page/TrashPageAlert.jsx

@@ -0,0 +1,107 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+import { toastError } from '../../util/apiNotification';
+
+import { createSubscribedElement } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
+import PageContainer from '../../services/PageContainer';
+import UserPicture from '../User/UserPicture';
+import EmptyTrashModal from '../EmptyTrashModal';
+
+
+const TrashPageAlert = (props) => {
+  const { t, appContainer, pageContainer } = props;
+  const {
+    path, isDeleted, revisionAuthor, updatedAt, hasChildren, isAbleToDeleteCompletely,
+  } = pageContainer.state;
+  const { currentUser } = appContainer;
+  const [isEmptyTrashModalShown, setIsEmptyTrashModalShown] = useState(false);
+
+  function openEmptyTrashModal() {
+    setIsEmptyTrashModalShown(true);
+  }
+
+  function closeEmptyTrashModal() {
+    setIsEmptyTrashModalShown(false);
+  }
+
+  async function onClickDeleteBtn() {
+    try {
+      await appContainer.apiv3Delete('/pages/empty-trash');
+      window.location.reload();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  function renderEmptyButton() {
+    return (
+      <button
+        href="#"
+        type="button"
+        className="btn btn-danger rounded-pill btn-sm ml-auto"
+        data-target="#emptyTrash"
+        onClick={openEmptyTrashModal}
+      >
+        <i className="icon-trash" aria-hidden="true"></i>{ t('modal_empty.empty_the_trash') }
+      </button>
+    );
+  }
+
+  function renderTrashPageManagementButtons() {
+    return (
+      <>
+        <button
+          type="button"
+          className="btn btn-outline-secondary rounded-pill btn-sm ml-auto mr-2"
+          data-target="#putBackPage"
+          data-toggle="modal"
+        >
+          <i className="icon-action-undo" aria-hidden="true"></i> { t('Put Back') }
+        </button>
+        <button
+          type="button"
+          className="btn btn-danger rounded-pill btn-sm mr-2"
+          disabled={!isAbleToDeleteCompletely}
+          data-target="#deletePage"
+          data-toggle="modal"
+        >
+          <i className="icon-fire" aria-hidden="true"></i> { t('Delete Completely') }
+        </button>
+      </>
+    );
+  }
+
+  return (
+    <>
+      <div className="alert alert-warning py-3 px-4 d-flex align-items-center">
+        <div>
+          This page is in the trash <i className="icon-trash" aria-hidden="true"></i>.
+          {isDeleted && <span><br /><UserPicture user={revisionAuthor} /> Deleted by {revisionAuthor.name} at {updatedAt}</span>}
+        </div>
+        {(currentUser.admin && path === '/trash' && hasChildren) && renderEmptyButton()}
+        {(isDeleted && currentUser != null) && renderTrashPageManagementButtons()}
+      </div>
+      <EmptyTrashModal isOpen={isEmptyTrashModalShown} toggle={closeEmptyTrashModal} onClickSubmit={onClickDeleteBtn} />
+    </>
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const TrashPageAlertWrapper = (props) => {
+  return createSubscribedElement(TrashPageAlert, props, [AppContainer, PageContainer]);
+};
+
+
+TrashPageAlert.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+};
+
+export default withTranslation()(TrashPageAlertWrapper);

+ 5 - 2
src/client/js/components/PageDuplicateModal.jsx

@@ -23,6 +23,7 @@ const PageDuplicateModal = (props) => {
   const { crowi } = appContainer.config;
 
   const [pageNameInput, setPageNameInput] = useState(path);
+  const [errorCode, setErrorCode] = useState(null);
   const [errorMessage, setErrorMessage] = useState(null);
 
   /**
@@ -35,12 +36,14 @@ const PageDuplicateModal = (props) => {
 
   async function clickDuplicateButtonHandler() {
     try {
+      setErrorCode(null);
       setErrorMessage(null);
       const res = await appContainer.apiPost('/pages.duplicate', { page_id: pageId, new_path: pageNameInput });
       const { page } = res;
-      window.location.href = `${page.path}?duplicated=${path}`;
+      window.location.href = encodeURI(`${page.path}?duplicated=${path}`);
     }
     catch (err) {
+      setErrorCode(err.code);
       setErrorMessage(err.message);
     }
   }
@@ -85,7 +88,7 @@ const PageDuplicateModal = (props) => {
         </div>
       </ModalBody>
       <ModalFooter>
-        <ApiErrorMessage errorMessage={errorMessage} linkPath={path} />
+        <ApiErrorMessage errorCode={errorCode} errorMessage={errorMessage} linkPath={path} />
         <button type="button" className="btn btn-primary" onClick={clickDuplicateButtonHandler}>Duplicate page</button>
       </ModalFooter>
     </Modal>

+ 30 - 13
src/client/js/components/PageManagement/ApiErrorMessage.jsx

@@ -4,27 +4,42 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
 const ApiErrorMessage = (props) => {
-  const { t, errorMessage, linkPath } = props;
+  const {
+    t, errorCode, errorMessage, linkPath,
+  } = props;
 
-  function renderMessage() {
-    switch (errorMessage) {
-      case 'Page exists':
+  function renderMessageByErrorCode() {
+    switch (errorCode) {
+      case 'already_exists':
         return (
-          <span className="text-danger">
+          <>
             <strong><i className="icon-fw icon-ban"></i>{ t('page_api_error.already_exists') }</strong>
             <small><a href={linkPath}>{linkPath} <i className="icon-login"></i></a></small>
-          </span>
+          </>
         );
       default:
         return null;
     }
   }
 
-  return (
-    <>
-      {renderMessage()}
-    </>
-  );
+  if (errorCode != null) {
+    return (
+      <span className="text-danger">
+        {renderMessageByErrorCode()}
+      </span>
+    );
+  }
+
+  if (errorMessage != null) {
+    return (
+      <span className="text-danger">
+        {errorMessage}
+      </span>
+    );
+  }
+
+  // render null if no error has occurred
+  return null;
 
   // TODO GW-79 Set according to error message
   // <div>
@@ -51,9 +66,11 @@ const ApiErrorMessage = (props) => {
 };
 
 ApiErrorMessage.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
+  t:            PropTypes.func.isRequired, //  i18next
+
+  errorCode:    PropTypes.string,
   errorMessage: PropTypes.string,
-  linkPath: PropTypes.string,
+  linkPath:     PropTypes.string,
 };
 
 export default withTranslation()(ApiErrorMessage);

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

@@ -8,6 +8,8 @@ import InterceptorManager from '@commons/service/interceptor-manager';
 import emojiStrategy from '../util/emojione/emoji_strategy_shrinked.json';
 import GrowiRenderer from '../util/GrowiRenderer';
 
+import Apiv1ErrorHandler from '../util/apiv1ErrorHandler';
+
 import {
   DetachCodeBlockInterceptor,
   RestoreCodeBlockInterceptor,
@@ -428,6 +430,13 @@ export default class AppContainer extends Container {
     if (res.data.ok) {
       return res.data;
     }
+
+    // Return error code if code is exist
+    if (res.data.code != null) {
+      const error = new Apiv1ErrorHandler(res.data.error, res.data.code);
+      throw error;
+    }
+
     throw new Error(res.data.error);
   }
 

+ 3 - 2
src/client/js/services/PageContainer.js

@@ -32,7 +32,6 @@ export default class PageContainer extends Container {
 
     const revisionId = mainContent.getAttribute('data-page-revision-id');
     const path = decodeURI(mainContent.getAttribute('data-path'));
-
     this.state = {
       // local page data
       markdown: null, // will be initialized after initStateMarkdown()
@@ -48,8 +47,10 @@ export default class PageContainer extends Container {
       createdAt: mainContent.getAttribute('data-page-created-at'),
       creator: JSON.parse(mainContent.getAttribute('data-page-creator')),
       updatedAt: mainContent.getAttribute('data-page-updated-at'),
-
+      isDeleted:  JSON.parse(mainContent.getAttribute('data-page-is-deleted')),
+      isAbleToDeleteCompletely:  JSON.parse(mainContent.getAttribute('data-page-is-able-to-delete-completely')),
       tags: [],
+      hasChildren: JSON.parse(mainContent.getAttribute('data-page-has-children')),
       templateTagData: mainContent.getAttribute('data-template-tags') || null,
 
       // latest(on remote) information

+ 12 - 0
src/client/js/util/apiv1ErrorHandler.js

@@ -0,0 +1,12 @@
+class Apiv1ErrorHandler extends Error {
+
+  constructor(message = '', code = '') {
+    super();
+
+    this.message = message;
+    this.code = code;
+  }
+
+}
+
+module.exports = Apiv1ErrorHandler;

+ 3 - 12
src/server/views/layout/layout.html

@@ -107,13 +107,7 @@
       {% endif %}
 
       {% if user %}
-      <!-- TODO GW-79 enable after refactoring  <li id="create-page-button" class="nav-item d-none d-md-block"></li> -->
-      <li class="nav-item d-none d-md-block">
-        <a class="nav-link create-page px-4" href="#" data-target="#create-page" data-toggle="modal">
-          <i class="icon-pencil mr-2"></i>
-          <span>{{ t('New') }}</span>
-        </a>
-      </li>
+      <li id="create-page-button" class="nav-item d-none d-md-block"></li>
       <li class="nav-item d-none d-md-block">
         <a class="nav-link" href="https://docs.growi.org/" target="_blank">
           <i class="icon-question mr-2"></i><span class="mr-2">{{ t('Help') }}</span><span class="small"><i class="icon-share-alt"></i></span>
@@ -149,17 +143,14 @@
 
 <div class="grw-fixed-controls-container d-md-none d-edit-none animated fadeInUp faster">
   <div class="grw-fixed-controls-button-container rounded-circle">
-    <!-- TODO GW-79 enable after refactoring <div id='create-page-button-icon'></div> -->
-    <button class="btn btn-lg btn-primary rounded-circle waves-effect waves-light" type="button" data-target="#create-page" data-toggle="modal">
-      <i class="icon-pencil"></i>
-    </button>
+    <div id='create-page-button-icon'></div>
   </div>
 </div>
 
 <!-- /#staff-credit -->
 <div id="staff-credit"></div>
 
-<!-- TODO GW-79 enable after refactoring <div id="page-create-modal"></div> -->
+<div id="page-create-modal"></div>
 {% include '../modal/shortcuts.html' %}
 
 {% block body_end %}

+ 1 - 19
src/server/views/widget/page_alerts.html

@@ -82,25 +82,7 @@
     {% endif %}
 
     {% if isTrashPage() %}
-    <div class="alert alert-warning py-3 px-4 d-flex align-items-center justify-content-between">
-      <div>
-        This page is in the trash <i class="icon-trash" aria-hidden="true"></i>.
-        {% if page.isDeleted() %}
-        <br>Deleted by <img src="{{ page.lastUpdateUser|picture }}" class="picture picture-sm rounded-circle"> {{ page.lastUpdateUser.name }} at {{ page.updatedAt|datetz('Y-m-d H:i:s') }}
-        {% endif %}
-      </div>
-      {% if user and user.admin and req.path == '/trash' and pages.length > 0 %}
-      <div>
-        <button href="#" class="btn btn-danger rounded-pill btn-sm" data-target="#emptyTrash" data-toggle="modal"><i class="icon-trash" aria-hidden="true"></i>{{ t('modal_empty.empty_the_trash') }}</button>
-      </div>
-      {% endif %}
-      {% if page.isDeleted() and user %}
-      <div>
-        <button href="#" class="btn btn-outline-secondary rounded-pill btn-sm mr-2" data-target="#putBackPage" data-toggle="modal"><i class="icon-action-undo" aria-hidden="true"></i> {{ t('Put Back') }}</button>
-        <button href="#" class="btn btn-danger rounded-pill btn-sm mr-2" {% if !user.canDeleteCompletely(page.creator._id) %} disabled="disabled" {% endif %} data-target="#deletePage" data-toggle="modal"><i class="icon-fire" aria-hidden="true"></i> {{ t('Delete Completely') }}</button>
-      </div>
-      {% endif %}
-    </div>
+      <div id="trash-page-alert"></div>
     {% endif %}
   </div>
 </div>

+ 5 - 0
src/server/views/widget/page_content.html

@@ -11,16 +11,21 @@
   data-page-has-draft-on-hackmd="{% if hasDraftOnHackmd %}{{ hasDraftOnHackmd.toString() }}{% endif %}"
   data-page-is-liked="{% if page.isLiked(user) %}true{% else %}false{% endif %}"
   data-page-is-seen="{% if page and page.isSeenUser(user) %}1{% else %}0{% endif %}"
+  data-page-is-deleted="{% if page.isDeleted() %}true{% else %}false{% endif %}"
+  data-page-is-able-to-delete-completely="{% if user.canDeleteCompletely(page.creator._id) %}true{% else %}false{% endif %}"
   data-slack-channels="{{ slack|default('') }}"
   data-page-created-at="{% if page %}{{ page.createdAt|datetz('Y/m/d H:i:s') }}{% endif %}"
   data-page-creator="{% if page %}{{ page.creator|json }}{% endif %}"
   data-page-updated-at="{% if page %}{{ page.updatedAt|datetz('Y/m/d H:i:s') }}{% endif %}"
+  data-page-has-children="{% if pages.length > 0 %}true{% else %}false{% endif %}"
   >
 {% else %}
 <div id="content-main" class="content-main"
   data-path="{{ encodeURI(path) }}"
   data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
   data-slack-channels="{{ slack|default('') }}"
+  data-page-is-deleted="{% if page.isDeleted() %}true{% else %}false{% endif %}"
+  data-page-has-children="{% if pages.length > 0 %}true{% else %}false{% endif %}"
   >
 {% endif %}