Răsfoiți Sursa

Merge pull request #5784 from weseek/feat/93173-empty-trash-function

feat: 93172 empty trash function
Yuki Takei 3 ani în urmă
părinte
comite
72c3ad825d

+ 3 - 4
packages/app/resource/locales/en_US/translation.json

@@ -436,16 +436,15 @@
     "delete_completely": "Delete completely",
     "delete_completely_restriction": "You don't have the authority to delete pages completely.",
     "recursively": "Delete pages under this path recursively.",
-    "completely": "Delete completely instead of putting it into trash.",
-    "empty_trash": "Empty trash",
-    "empty_trash_button": "Empty trash",
-    "empty_trash_alert": "These pages have been permanently deleted and this operation cannot be canceled."
+    "completely": "Delete completely instead of putting it into trash."
   },
   "deleted_pages": "{{path}} has been deleted",
   "deleted_pages_completely": "{{path}} has been deleted completely",
   "renamed_pages": "{{path}} has been renamed",
+  "empty_trash": "The trash has been emptied",
   "modal_empty":{
     "empty_the_trash": "Empty The Trash",
+    "empty_the_trash_button": "Empty The Trash",
     "notice": "The pages deleted completely are unrecoverable."
   },
   "modal_duplicate": {

+ 3 - 4
packages/app/resource/locales/ja_JP/translation.json

@@ -435,16 +435,15 @@
     "delete_completely": "完全削除",
     "delete_completely_restriction": "完全削除をするための権限がありません。",
     "recursively": "配下のページも削除します",
-    "completely": "ゴミ箱を経由せず、完全に削除します",
-    "empty_trash": "ゴミ箱を空にする",
-    "empty_trash_button": "空にする",
-    "empty_trash_alert": "これらのページは完全に削除され、この操作は取り消すことができません"
+    "completely": "ゴミ箱を経由せず、完全に削除します"
   },
   "deleted_pages": "{{path}} をゴミ箱に入れました",
   "deleted_pages_completely": "{{path}} を完全に削除しました",
   "renamed_pages": "{{path}} を移動/名前変更しました",
+  "empty_trash": "ゴミ箱を空にしました",
   "modal_empty":{
     "empty_the_trash": "ゴミ箱を空にする",
+    "empty_the_trash_button": "空にする",
     "notice": "完全削除したページは元に戻すことができません"
   },
   "modal_duplicate": {

+ 4 - 5
packages/app/resource/locales/zh_CN/translation.json

@@ -414,16 +414,15 @@
 		"delete_completely": "Delete completely",
 		"delete_completely_restriction": "You don't have the authority to delete pages completely.",
 		"recursively": "Delete children of <code>%s</code> recursively.",
-		"completely": "Delete completely instead of putting it into trash.",
-    "empty_trash": "清空垃圾",
-    "empty_trash_button": "清空垃圾",
-    "empty_trash_alert": "这些页面已被永久删除,此操作无法撤消"
+		"completely": "Delete completely instead of putting it into trash."
   },
   "deleted_pages": "将 {{path}} 放入垃圾箱",
   "deleted_pages_completely": "{{path}} 已被完全删除",
   "renamed_pages": "移动/重命名 {{path}}",
+  "empty_trash": "清空垃圾",
 	"modal_empty": {
-		"empty_the_trash": "Empty The Trash",
+		"empty_the_trash": "清空垃圾",
+    "empty_the_trash_button": "清空垃圾",
 		"notice": "完全删除的页面是不可恢复的。"
 	},
 	"modal_duplicate": {

+ 9 - 8
packages/app/src/client/base.jsx

@@ -1,22 +1,22 @@
 import React from 'react';
 
+import AppContainer from '~/client/services/AppContainer';
+import SocketIoContainer from '~/client/services/SocketIoContainer';
+import { DescendantsPageListModal } from '~/components/DescendantsPageListModal';
+import PutbackPageModal from '~/components/PutbackPageModal';
 import Xss from '~/services/xss';
 import loggerFactory from '~/utils/logger';
 
+import EmptyTrashModal from '../components/EmptyTrashModal';
+import HotkeysManager from '../components/Hotkeys/HotkeysManager';
 import GrowiNavbar from '../components/Navbar/GrowiNavbar';
 import GrowiNavbarBottom from '../components/Navbar/GrowiNavbarBottom';
-import HotkeysManager from '../components/Hotkeys/HotkeysManager';
+import PageAccessoriesModal from '../components/PageAccessoriesModal';
 import PageCreateModal from '../components/PageCreateModal';
 import PageDeleteModal from '../components/PageDeleteModal';
 import PageDuplicateModal from '../components/PageDuplicateModal';
-import PageRenameModal from '../components/PageRenameModal';
 import PagePresentationModal from '../components/PagePresentationModal';
-import PageAccessoriesModal from '../components/PageAccessoriesModal';
-import PutbackPageModal from '~/components/PutbackPageModal';
-
-import AppContainer from '~/client/services/AppContainer';
-import SocketIoContainer from '~/client/services/SocketIoContainer';
-import { DescendantsPageListModal } from '~/components/DescendantsPageListModal';
+import PageRenameModal from '../components/PageRenameModal';
 
 const logger = loggerFactory('growi:cli:app');
 
@@ -48,6 +48,7 @@ const componentMappings = {
 
   'page-create-modal': <PageCreateModal />,
   'page-delete-modal': <PageDeleteModal />,
+  'empty-trash-modal': <EmptyTrashModal />,
   'page-duplicate-modal': <PageDuplicateModal />,
   'page-rename-modal': <PageRenameModal />,
   'page-presentation-modal': <PagePresentationModal />,

+ 12 - 9
packages/app/src/components/EmptyTrashButton.tsx

@@ -1,20 +1,21 @@
-import React from 'react';
+import React, { useCallback } from 'react';
 
 import { useTranslation } from 'react-i18next';
 
+import { toastSuccess } from '~/client/util/apiNotification';
 import {
   IDataWithMeta,
   IPageHasId,
   IPageInfo,
 } from '~/interfaces/page';
-import { usePageDeleteModal } from '~/stores/modal';
+import { useEmptyTrashModal } from '~/stores/modal';
 import { useSWRxDescendantsPageListForCurrrentPath, useSWRxPageInfoForList } from '~/stores/page';
 
 
 const EmptyTrashButton = () => {
   const { t } = useTranslation();
-  const { open: openDeleteModal } = usePageDeleteModal();
-  const { data: pagingResult } = useSWRxDescendantsPageListForCurrrentPath();
+  const { open: openEmptyTrashModal } = useEmptyTrashModal();
+  const { data: pagingResult, mutate } = useSWRxDescendantsPageListForCurrrentPath();
 
   const pageIds = pagingResult?.items?.map(page => page._id);
   const { injectTo } = useSWRxPageInfoForList(pageIds, true, true);
@@ -30,12 +31,14 @@ const EmptyTrashButton = () => {
     pageWithMetas = injectTo(dataWithMetas);
   }
 
-  const onDeletedHandler = (...args) => {
-    // process after multipe pages delete api
-  };
+  const onEmptiedTrashHandler = useCallback(() => {
+    toastSuccess(t('empty_trash'));
+
+    mutate();
+  }, [t, mutate]);
 
   const emptyTrashClickHandler = () => {
-    openDeleteModal(pageWithMetas, { onDeleted: onDeletedHandler, emptyTrash: true });
+    openEmptyTrashModal(pageWithMetas, { onEmptiedTrash: onEmptiedTrashHandler });
   };
 
   return (
@@ -46,7 +49,7 @@ const EmptyTrashButton = () => {
         onClick={() => emptyTrashClickHandler()}
       >
         <i className="icon-fw icon-trash"></i>
-        <div>{t('modal_delete.empty_trash')}</div>
+        <div>{t('modal_empty.empty_the_trash')}</div>
       </button>
     </div>
   );

+ 0 - 71
packages/app/src/components/EmptyTrashModal.jsx

@@ -1,71 +0,0 @@
-import React, { useState } from 'react';
-import PropTypes from 'prop-types';
-
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
-
-import { withTranslation } from 'react-i18next';
-import { withUnstatedContainers } from './UnstatedUtils';
-
-import SocketIoContainer from '~/client/services/SocketIoContainer';
-import AppContainer from '~/client/services/AppContainer';
-import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
-
-const EmptyTrashModal = (props) => {
-  const {
-    t, isOpen, onClose, appContainer, socketIoContainer,
-  } = props;
-
-  const [errs, setErrs] = useState(null);
-
-  async function emptyTrash() {
-    setErrs(null);
-
-    try {
-      await appContainer.apiv3Delete('/pages/empty-trash');
-      window.location.reload();
-    }
-    catch (err) {
-      setErrs(err);
-    }
-  }
-
-  function emptyButtonHandler() {
-    emptyTrash();
-  }
-
-  return (
-    <Modal isOpen={isOpen} toggle={onClose} className="grw-create-page">
-      <ModalHeader tag="h4" toggle={onClose} className="bg-danger text-light">
-        { t('modal_empty.empty_the_trash')}
-      </ModalHeader>
-      <ModalBody>
-        { t('modal_empty.notice')}
-      </ModalBody>
-      <ModalFooter>
-        <ApiErrorMessageList errs={errs} />
-        <button type="button" className="btn btn-danger" onClick={emptyButtonHandler}>
-          <i className="icon-trash mr-2" aria-hidden="true"></i> Empty
-        </button>
-      </ModalFooter>
-    </Modal>
-  );
-};
-
-/**
- * Wrapper component for using unstated
- */
-const EmptyTrashModalWrapper = withUnstatedContainers(EmptyTrashModal, [AppContainer, SocketIoContainer]);
-
-
-EmptyTrashModal.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  socketIoContainer: PropTypes.instanceOf(SocketIoContainer),
-
-  isOpen: PropTypes.bool.isRequired,
-  onClose: PropTypes.func.isRequired,
-};
-
-export default withTranslation()(EmptyTrashModalWrapper);

+ 89 - 0
packages/app/src/components/EmptyTrashModal.tsx

@@ -0,0 +1,89 @@
+import React, {
+  useState, FC,
+} from 'react';
+
+import { useTranslation } from 'react-i18next';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+import { apiv3Delete } from '~/client/util/apiv3-client';
+import { useEmptyTrashModal } from '~/stores/modal';
+
+import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
+
+const EmptyTrashModal: FC = () => {
+  const { t } = useTranslation();
+
+  const { data: emptyTrashModalData, close: closeEmptyTrashModal } = useEmptyTrashModal();
+
+  const isOpened = emptyTrashModalData?.isOpened ?? false;
+
+  const [errs, setErrs] = useState<Error[] | null>(null);
+
+  async function emptyTrash() {
+    if (emptyTrashModalData == null || emptyTrashModalData.pages == null) {
+      return;
+    }
+
+    try {
+      await apiv3Delete('/pages/empty-trash');
+      const onEmptiedTrash = emptyTrashModalData.opts?.onEmptiedTrash;
+      if (onEmptiedTrash != null) {
+        onEmptiedTrash();
+      }
+      closeEmptyTrashModal();
+    }
+    catch (err) {
+      setErrs([err]);
+    }
+  }
+
+  async function emptyTrashButtonHandler() {
+    await emptyTrash();
+  }
+
+  const renderPagePaths = () => {
+    const pages = emptyTrashModalData?.pages;
+
+    if (pages != null) {
+      return pages.map(page => (
+        <p key={page.data._id} className="mb-1">
+          <code>{ page.data.path }</code>
+        </p>
+      ));
+    }
+    return <></>;
+  };
+
+  return (
+    <Modal size="lg" isOpen={isOpened} toggle={closeEmptyTrashModal} data-testid="page-delete-modal" className="grw-create-page">
+      <ModalHeader tag="h4" toggle={closeEmptyTrashModal} className="bg-danger text-light">
+        <i className="icon-fw icon-fire"></i>
+        {t('modal_empty.empty_the_trash')}
+      </ModalHeader>
+      <ModalBody>
+        <div className="form-group grw-scrollable-modal-body pb-1">
+          <label>{ t('modal_delete.deleting_page') }:</label><br />
+          {/* Todo: change the way to show path on modal when too many pages are selected */}
+          {renderPagePaths()}
+        </div>
+        {t('modal_empty.notice')}
+      </ModalBody>
+      <ModalFooter>
+        <ApiErrorMessageList errs={errs} />
+        <button
+          type="button"
+          className="btn btn-danger"
+          onClick={emptyTrashButtonHandler}
+        >
+          <i className="mr-1 icon-fire" aria-hidden="true"></i>
+          {t('modal_empty.empty_the_trash_button')}
+        </button>
+      </ModalFooter>
+    </Modal>
+
+  );
+};
+
+export default EmptyTrashModal;

+ 5 - 27
packages/app/src/components/PageDeleteModal.tsx

@@ -1,5 +1,5 @@
 import React, {
-  useState, FC, useMemo, useCallback,
+  useState, FC, useMemo,
 } from 'react';
 
 import { useTranslation } from 'react-i18next';
@@ -45,7 +45,6 @@ const PageDeleteModal: FC = () => {
   const { data: deleteModalData, close: closeDeleteModal } = usePageDeleteModal();
 
   const isOpened = deleteModalData?.isOpened ?? false;
-  const emptyTrash = deleteModalData?.opts?.emptyTrash ?? false;
 
   const notOperatablePages: IPageToDeleteWithMeta[] = (deleteModalData?.pages ?? [])
     .filter(p => !isIPageInfoForEntity(p.meta));
@@ -126,7 +125,6 @@ const PageDeleteModal: FC = () => {
         if (onDeleted != null) {
           onDeleted(data.paths, data.isRecursively, data.isCompletely);
         }
-
         closeDeleteModal();
       }
       catch (err) {
@@ -212,10 +210,6 @@ const PageDeleteModal: FC = () => {
     );
   }
 
-  const renderCompletelyDeleteAlert = useMemo(() => {
-    return <p className="form-text mt-0">{t('modal_delete.empty_trash_alert')}</p>;
-  }, [t]);
-
   const renderPagePathsToDelete = () => {
     const pages = injectedPages != null && injectedPages.length > 0 ? injectedPages : deleteModalData?.pages;
 
@@ -230,28 +224,11 @@ const PageDeleteModal: FC = () => {
     return <></>;
   };
 
-  const renderDeleteModalOptions = useCallback(() => {
-    if (emptyTrash) {
-      return renderCompletelyDeleteAlert;
-    }
-
-    if (!isDeletable) {
-      return;
-    }
-
-    return (
-      <>
-        {renderDeleteRecursivelyForm()}
-        {!forceDeleteCompletelyMode && renderDeleteCompletelyForm()}
-      </>
-    );
-  }, [t, deleteModalData, isDeleteCompletely, isDeleteRecursively]);
-
   return (
     <Modal size="lg" isOpen={isOpened} toggle={closeDeleteModal} data-testid="page-delete-modal" className="grw-create-page">
       <ModalHeader tag="h4" toggle={closeDeleteModal} className={`bg-${deleteIconAndKey[deleteMode].color} text-light`}>
         <i className={`icon-fw icon-${deleteIconAndKey[deleteMode].icon}`}></i>
-        { emptyTrash ? t('modal_delete.empty_trash') : t(`modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`) }
+        { t(`modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`) }
       </ModalHeader>
       <ModalBody>
         <div className="form-group grw-scrollable-modal-body pb-1">
@@ -259,7 +236,8 @@ const PageDeleteModal: FC = () => {
           {/* Todo: change the way to show path on modal when too many pages are selected */}
           {renderPagePathsToDelete()}
         </div>
-        {renderDeleteModalOptions()}
+        { isDeletable && renderDeleteRecursivelyForm()}
+        { isDeletable && !forceDeleteCompletelyMode && renderDeleteCompletelyForm() }
       </ModalBody>
       <ModalFooter>
         <ApiErrorMessageList errs={errs} />
@@ -270,7 +248,7 @@ const PageDeleteModal: FC = () => {
           onClick={deleteButtonHandler}
         >
           <i className={`mr-1 icon-${deleteIconAndKey[deleteMode].icon}`} aria-hidden="true"></i>
-          { emptyTrash ? t('modal_delete.empty_trash_button') : t(`modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`) }
+          { t(`modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`) }
         </button>
       </ModalFooter>
     </Modal>

+ 17 - 4
packages/app/src/server/routes/apiv3/pages.js

@@ -1,16 +1,15 @@
-import loggerFactory from '~/utils/logger';
 
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
+import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
-const express = require('express');
 const { pathUtils, pagePathUtils } = require('@growi/core');
-const mongoose = require('mongoose');
-
+const express = require('express');
 const { body } = require('express-validator');
 const { query } = require('express-validator');
+const mongoose = require('mongoose');
 
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
@@ -549,6 +548,20 @@ module.exports = (crowi) => {
   router.delete('/empty-trash', accessTokenParser, loginRequired, adminRequired, csrf, apiV3FormValidator, async(req, res) => {
     const options = {};
 
+    const pagesInTrash = await Page.findChildrenByParentPathOrIdAndViewer('/trash', req.user);
+
+    const deletablePages = crowi.pageService.filterPagesByCanDeleteCompletely(pagesInTrash, req.user, true);
+
+    if (deletablePages.length === 0) {
+      const msg = 'No pages can be deleted.';
+      return res.apiv3Err(new ErrorV3(msg), 500);
+    }
+
+    if (deletablePages.length < pagesInTrash.length) {
+      const msg = 'Some pages can not be deleted.';
+      return res.apiv3Err(new ErrorV3(msg), 500);
+    }
+
     try {
       const pages = await crowi.pageService.emptyTrashPage(req.user, options);
       return res.apiv3({ pages });

+ 1 - 0
packages/app/src/server/views/layout/layout.html

@@ -105,6 +105,7 @@
 
 <div id="page-create-modal"></div>
 <div id="page-delete-modal"></div>
+<div id="empty-trash-modal"></div>
 <div id="page-duplicate-modal"></div>
 <div id="page-rename-modal"></div>
 <div id="page-presentation-modal"></div>

+ 43 - 1
packages/app/src/stores/modal.tsx

@@ -33,9 +33,11 @@ export const usePageCreateModal = (status?: CreateModalStatus): SWRResponse<Crea
   };
 };
 
+/*
+* PageDeleteModal
+*/
 export type IDeleteModalOption = {
   onDeleted?: OnDeletedFunction,
-  emptyTrash?: true,
 }
 
 type DeleteModalStatus = {
@@ -71,6 +73,46 @@ export const usePageDeleteModal = (status?: DeleteModalStatus): SWRResponse<Dele
   };
 };
 
+/*
+* EmptyTrashModal
+*/
+type IEmptyTrashModalOption = {
+  onEmptiedTrash?: () => void,
+}
+
+type EmptyTrashModalStatus = {
+  isOpened: boolean,
+  pages?: IPageToDeleteWithMeta[],
+  opts?: IEmptyTrashModalOption,
+}
+
+type EmptyTrashModalStatusUtils = {
+  open(
+    pages?: IPageToDeleteWithMeta[],
+    opts?: IEmptyTrashModalOption,
+  ): Promise<EmptyTrashModalStatus | undefined>,
+  close(): Promise<EmptyTrashModalStatus | undefined>,
+}
+
+export const useEmptyTrashModal = (status?: EmptyTrashModalStatus): SWRResponse<EmptyTrashModalStatus, Error> & EmptyTrashModalStatusUtils => {
+  const initialData: EmptyTrashModalStatus = {
+    isOpened: false,
+    pages: [],
+  };
+  const swrResponse = useStaticSWR<EmptyTrashModalStatus, Error>('emptyTrashModalStatus', status, { fallbackData: initialData });
+
+  return {
+    ...swrResponse,
+    open: (
+        pages?: IPageToDeleteWithMeta[],
+        opts?: IEmptyTrashModalOption,
+    ) => swrResponse.mutate({
+      isOpened: true, pages, opts,
+    }),
+    close: () => swrResponse.mutate({ isOpened: false }),
+  };
+};
+
 /*
 * PageDuplicateModal
 */