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

Merge pull request #5741 from weseek/feat/93173-93282-add-all-delete-button-and-modal-in-trash

feat: 93173 93282 create empty trash button and modal in trash
Yuki Takei 4 лет назад
Родитель
Сommit
fce80861b3

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

@@ -436,7 +436,10 @@
     "delete_completely": "Delete completely",
     "delete_completely": "Delete completely",
     "delete_completely_restriction": "You don't have the authority to delete pages completely.",
     "delete_completely_restriction": "You don't have the authority to delete pages completely.",
     "recursively": "Delete pages under this path recursively.",
     "recursively": "Delete pages under this path recursively.",
-    "completely": "Delete completely instead of putting it into trash."
+    "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."
   },
   },
   "deleted_pages": "{{path}} has been deleted",
   "deleted_pages": "{{path}} has been deleted",
   "deleted_pages_completely": "{{path}} has been deleted completely",
   "deleted_pages_completely": "{{path}} has been deleted completely",

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

@@ -435,7 +435,10 @@
     "delete_completely": "完全削除",
     "delete_completely": "完全削除",
     "delete_completely_restriction": "完全削除をするための権限がありません。",
     "delete_completely_restriction": "完全削除をするための権限がありません。",
     "recursively": "配下のページも削除します",
     "recursively": "配下のページも削除します",
-    "completely": "ゴミ箱を経由せず、完全に削除します"
+    "completely": "ゴミ箱を経由せず、完全に削除します",
+    "empty_trash": "ゴミ箱を空にする",
+    "empty_trash_button": "空にする",
+    "empty_trash_alert": "これらのページは完全に削除され、この操作は取り消すことができません"
   },
   },
   "deleted_pages": "{{path}} をゴミ箱に入れました",
   "deleted_pages": "{{path}} をゴミ箱に入れました",
   "deleted_pages_completely": "{{path}} を完全に削除しました",
   "deleted_pages_completely": "{{path}} を完全に削除しました",

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

@@ -414,7 +414,10 @@
 		"delete_completely": "Delete completely",
 		"delete_completely": "Delete completely",
 		"delete_completely_restriction": "You don't have the authority to delete pages completely.",
 		"delete_completely_restriction": "You don't have the authority to delete pages completely.",
 		"recursively": "Delete children of <code>%s</code> recursively.",
 		"recursively": "Delete children of <code>%s</code> recursively.",
-		"completely": "Delete completely instead of putting it into trash."
+		"completely": "Delete completely instead of putting it into trash.",
+    "empty_trash": "清空垃圾",
+    "empty_trash_button": "清空垃圾",
+    "empty_trash_alert": "这些页面已被永久删除,此操作无法撤消"
   },
   },
   "deleted_pages": "将 {{path}} 放入垃圾箱",
   "deleted_pages": "将 {{path}} 放入垃圾箱",
   "deleted_pages_completely": "{{path}} 已被完全删除",
   "deleted_pages_completely": "{{path}} 已被完全删除",

+ 5 - 2
packages/app/src/components/CustomNavigation/CustomNav.jsx

@@ -1,6 +1,7 @@
 import React, {
 import React, {
   useEffect, useState, useRef, useMemo, useCallback,
   useEffect, useState, useRef, useMemo, useCallback,
 } from 'react';
 } from 'react';
+
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import {
 import {
   Nav, NavItem, NavLink,
   Nav, NavItem, NavLink,
@@ -87,7 +88,7 @@ export const CustomNavTab = (props) => {
   const [sliderMarginLeft, setSliderMarginLeft] = useState(0);
   const [sliderMarginLeft, setSliderMarginLeft] = useState(0);
 
 
   const {
   const {
-    activeTab, navTabMapping, onNavSelected, hideBorderBottom, breakpointToHideInactiveTabsDown,
+    activeTab, navTabMapping, onNavSelected, hideBorderBottom, breakpointToHideInactiveTabsDown, navRightElement,
   } = props;
   } = props;
 
 
   const navTabRefs = useMemo(() => {
   const navTabRefs = useMemo(() => {
@@ -149,7 +150,7 @@ export const CustomNavTab = (props) => {
 
 
   return (
   return (
     <div className="grw-custom-nav-tab">
     <div className="grw-custom-nav-tab">
-      <div ref={navContainer}>
+      <div ref={navContainer} className="d-flex justify-content-between">
         <Nav className="nav-title">
         <Nav className="nav-title">
           {Object.entries(navTabMapping).map(([key, value]) => {
           {Object.entries(navTabMapping).map(([key, value]) => {
 
 
@@ -169,6 +170,7 @@ export const CustomNavTab = (props) => {
             );
             );
           })}
           })}
         </Nav>
         </Nav>
+        {navRightElement}
       </div>
       </div>
       <hr className="my-0 grw-nav-slide-hr border-none" style={{ width: `${sliderWidth}%`, marginLeft: `${sliderMarginLeft}%` }} />
       <hr className="my-0 grw-nav-slide-hr border-none" style={{ width: `${sliderWidth}%`, marginLeft: `${sliderMarginLeft}%` }} />
       { !hideBorderBottom && <hr className="my-0 border-top-0 border-bottom" /> }
       { !hideBorderBottom && <hr className="my-0 border-top-0 border-bottom" /> }
@@ -183,6 +185,7 @@ CustomNavTab.propTypes = {
   onNavSelected: PropTypes.func,
   onNavSelected: PropTypes.func,
   hideBorderBottom: PropTypes.bool,
   hideBorderBottom: PropTypes.bool,
   breakpointToHideInactiveTabsDown: PropTypes.oneOf(['xs', 'sm', 'md', 'lg', 'xl']),
   breakpointToHideInactiveTabsDown: PropTypes.oneOf(['xs', 'sm', 'md', 'lg', 'xl']),
+  navRightElement: PropTypes.node,
 };
 };
 
 
 CustomNavTab.defaultProps = {
 CustomNavTab.defaultProps = {

+ 4 - 1
packages/app/src/components/CustomNavigation/CustomNavAndContents.jsx

@@ -1,4 +1,5 @@
 import React, { useState } from 'react';
 import React, { useState } from 'react';
+
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 import CustomNav, { CustomNavTab, CustomNavDropdown } from './CustomNav';
 import CustomNav, { CustomNavTab, CustomNavDropdown } from './CustomNav';
@@ -7,7 +8,7 @@ import CustomTabContent from './CustomTabContent';
 
 
 const CustomNavAndContents = (props) => {
 const CustomNavAndContents = (props) => {
   const {
   const {
-    navTabMapping, defaultTabIndex, navigationMode, tabContentClasses, breakpointToHideInactiveTabsDown,
+    navTabMapping, defaultTabIndex, navigationMode, tabContentClasses, breakpointToHideInactiveTabsDown, navRightElement,
   } = props;
   } = props;
   const [activeTab, setActiveTab] = useState(Object.keys(props.navTabMapping)[defaultTabIndex || 0]);
   const [activeTab, setActiveTab] = useState(Object.keys(props.navTabMapping)[defaultTabIndex || 0]);
 
 
@@ -31,6 +32,7 @@ const CustomNavAndContents = (props) => {
         navTabMapping={navTabMapping}
         navTabMapping={navTabMapping}
         onNavSelected={setActiveTab}
         onNavSelected={setActiveTab}
         breakpointToHideInactiveTabsDown={breakpointToHideInactiveTabsDown}
         breakpointToHideInactiveTabsDown={breakpointToHideInactiveTabsDown}
+        navRightElement={navRightElement}
       />
       />
       <CustomTabContent activeTab={activeTab} navTabMapping={navTabMapping} additionalClassNames={tabContentClasses} />
       <CustomTabContent activeTab={activeTab} navTabMapping={navTabMapping} additionalClassNames={tabContentClasses} />
     </>
     </>
@@ -43,6 +45,7 @@ CustomNavAndContents.propTypes = {
   navigationMode: PropTypes.oneOf(['both', 'tab', 'dropdown']),
   navigationMode: PropTypes.oneOf(['both', 'tab', 'dropdown']),
   tabContentClasses: PropTypes.arrayOf(PropTypes.string),
   tabContentClasses: PropTypes.arrayOf(PropTypes.string),
   breakpointToHideInactiveTabsDown: PropTypes.oneOf(['xs', 'sm', 'md', 'lg', 'xl']),
   breakpointToHideInactiveTabsDown: PropTypes.oneOf(['xs', 'sm', 'md', 'lg', 'xl']),
+  navRightElement: PropTypes.node,
 };
 };
 CustomNavAndContents.defaultProps = {
 CustomNavAndContents.defaultProps = {
   navigationMode: 'tab',
   navigationMode: 'tab',

+ 55 - 0
packages/app/src/components/EmptyTrashButton.tsx

@@ -0,0 +1,55 @@
+import React from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import {
+  IDataWithMeta,
+  IPageHasId,
+  IPageInfo,
+} from '~/interfaces/page';
+import { usePageDeleteModal } from '~/stores/modal';
+import { useSWRxDescendantsPageListForCurrrentPath, useSWRxPageInfoForList } from '~/stores/page';
+
+
+const EmptyTrashButton = () => {
+  const { t } = useTranslation();
+  const { open: openDeleteModal } = usePageDeleteModal();
+  const { data: pagingResult } = useSWRxDescendantsPageListForCurrrentPath();
+
+  const pageIds = pagingResult?.items?.map(page => page._id);
+  const { injectTo } = useSWRxPageInfoForList(pageIds, true, true);
+
+  let pageWithMetas: IDataWithMeta<IPageHasId, IPageInfo>[] = [];
+
+  const convertToIDataWithMeta = (page) => {
+    return { data: page };
+  };
+
+  if (pagingResult != null) {
+    const dataWithMetas = pagingResult.items.map(page => convertToIDataWithMeta(page));
+    pageWithMetas = injectTo(dataWithMetas);
+  }
+
+  const onDeletedHandler = (...args) => {
+    // process after multipe pages delete api
+  };
+
+  const emptyTrashClickHandler = () => {
+    openDeleteModal(pageWithMetas, { onDeleted: onDeletedHandler, emptyTrash: true });
+  };
+
+  return (
+    <div className="d-flex align-items-center">
+      <button
+        type="button"
+        className="btn btn-outline-secondary rounded-pill text-danger d-flex align-items-center"
+        onClick={() => emptyTrashClickHandler()}
+      >
+        <i className="icon-fw icon-trash"></i>
+        <div>{t('modal_delete.empty_trash')}</div>
+      </button>
+    </div>
+  );
+};
+
+export default EmptyTrashButton;

+ 37 - 13
packages/app/src/components/PageDeleteModal.tsx

@@ -1,22 +1,26 @@
-import React, { useState, FC, useMemo } from 'react';
+import React, {
+  useState, FC, useMemo, useCallback,
+} from 'react';
+
+import { useTranslation } from 'react-i18next';
 import {
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
-import { useTranslation } from 'react-i18next';
 
 
 import { apiPost } from '~/client/util/apiv1-client';
 import { apiPost } from '~/client/util/apiv1-client';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { apiv3Post } from '~/client/util/apiv3-client';
-import { usePageDeleteModal } from '~/stores/modal';
-import loggerFactory from '~/utils/logger';
-
+import { HasObjectId } from '~/interfaces/has-object-id';
 import {
 import {
   IDeleteSinglePageApiv1Result, IDeleteManyPageApiv3Result, IPageToDeleteWithMeta, IDataWithMeta, isIPageInfoForEntity, IPageInfoForEntity,
   IDeleteSinglePageApiv1Result, IDeleteManyPageApiv3Result, IPageToDeleteWithMeta, IDataWithMeta, isIPageInfoForEntity, IPageInfoForEntity,
 } from '~/interfaces/page';
 } from '~/interfaces/page';
-import { HasObjectId } from '~/interfaces/has-object-id';
+import { usePageDeleteModal } from '~/stores/modal';
+import { useSWRxPageInfoForList } from '~/stores/page';
+import loggerFactory from '~/utils/logger';
+
 
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
+
 import { isTrashPage } from '^/../core/src/utils/page-path-utils';
 import { isTrashPage } from '^/../core/src/utils/page-path-utils';
-import { useSWRxPageInfoForList } from '~/stores/page';
 
 
 
 
 const logger = loggerFactory('growi:cli:PageDeleteModal');
 const logger = loggerFactory('growi:cli:PageDeleteModal');
@@ -41,6 +45,7 @@ const PageDeleteModal: FC = () => {
   const { data: deleteModalData, close: closeDeleteModal } = usePageDeleteModal();
   const { data: deleteModalData, close: closeDeleteModal } = usePageDeleteModal();
 
 
   const isOpened = deleteModalData?.isOpened ?? false;
   const isOpened = deleteModalData?.isOpened ?? false;
+  const emptyTrash = deleteModalData?.opts?.emptyTrash ?? false;
 
 
   const notOperatablePages: IPageToDeleteWithMeta[] = (deleteModalData?.pages ?? [])
   const notOperatablePages: IPageToDeleteWithMeta[] = (deleteModalData?.pages ?? [])
     .filter(p => !isIPageInfoForEntity(p.meta));
     .filter(p => !isIPageInfoForEntity(p.meta));
@@ -207,6 +212,10 @@ const PageDeleteModal: FC = () => {
     );
     );
   }
   }
 
 
+  const renderCompletelyDeleteAlert = useMemo(() => {
+    return <p className="form-text mt-0">{t('modal_delete.empty_trash_alert')}</p>;
+  }, [t]);
+
   const renderPagePathsToDelete = () => {
   const renderPagePathsToDelete = () => {
     const pages = injectedPages != null && injectedPages.length > 0 ? injectedPages : deleteModalData?.pages;
     const pages = injectedPages != null && injectedPages.length > 0 ? injectedPages : deleteModalData?.pages;
 
 
@@ -221,21 +230,36 @@ const PageDeleteModal: FC = () => {
     return <></>;
     return <></>;
   };
   };
 
 
+  const renderDeleteModalOptions = useCallback(() => {
+    if (emptyTrash) {
+      return renderCompletelyDeleteAlert;
+    }
+
+    if (!isDeletable) {
+      return;
+    }
+
+    return (
+      <>
+        {renderDeleteRecursivelyForm()}
+        {!forceDeleteCompletelyMode && renderDeleteCompletelyForm()}
+      </>
+    );
+  }, [t, deleteModalData, isDeleteCompletely, isDeleteRecursively]);
+
   return (
   return (
     <Modal size="lg" isOpen={isOpened} toggle={closeDeleteModal} data-testid="page-delete-modal" className="grw-create-page">
     <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`}>
       <ModalHeader tag="h4" toggle={closeDeleteModal} className={`bg-${deleteIconAndKey[deleteMode].color} text-light`}>
         <i className={`icon-fw icon-${deleteIconAndKey[deleteMode].icon}`}></i>
         <i className={`icon-fw icon-${deleteIconAndKey[deleteMode].icon}`}></i>
-        { t(`modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`) }
+        { emptyTrash ? t('modal_delete.empty_trash') : t(`modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`) }
       </ModalHeader>
       </ModalHeader>
       <ModalBody>
       <ModalBody>
         <div className="form-group grw-scrollable-modal-body pb-1">
         <div className="form-group grw-scrollable-modal-body pb-1">
           <label>{ t('modal_delete.deleting_page') }:</label><br />
           <label>{ t('modal_delete.deleting_page') }:</label><br />
           {/* Todo: change the way to show path on modal when too many pages are selected */}
           {/* Todo: change the way to show path on modal when too many pages are selected */}
-          {/* https://redmine.weseek.co.jp/issues/82787 */}
           {renderPagePathsToDelete()}
           {renderPagePathsToDelete()}
         </div>
         </div>
-        { isDeletable && renderDeleteRecursivelyForm()}
-        { isDeletable && !forceDeleteCompletelyMode && renderDeleteCompletelyForm() }
+        {renderDeleteModalOptions()}
       </ModalBody>
       </ModalBody>
       <ModalFooter>
       <ModalFooter>
         <ApiErrorMessageList errs={errs} />
         <ApiErrorMessageList errs={errs} />
@@ -245,8 +269,8 @@ const PageDeleteModal: FC = () => {
           disabled={!isDeletable}
           disabled={!isDeletable}
           onClick={deleteButtonHandler}
           onClick={deleteButtonHandler}
         >
         >
-          <i className={`icon-${deleteIconAndKey[deleteMode].icon}`} aria-hidden="true"></i>
-          { t(`modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`) }
+          <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}`) }
         </button>
         </button>
       </ModalFooter>
       </ModalFooter>
     </Modal>
     </Modal>

+ 9 - 2
packages/app/src/components/TrashPageList.jsx

@@ -1,9 +1,12 @@
 import React, { useMemo } from 'react';
 import React, { useMemo } from 'react';
+
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
-import PageListIcon from './Icons/PageListIcon';
+
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
 import { DescendantsPageListForCurrentPath } from './DescendantsPageList';
 import { DescendantsPageListForCurrentPath } from './DescendantsPageList';
+import EmptyTrashButton from './EmptyTrashButton';
+import PageListIcon from './Icons/PageListIcon';
 
 
 
 
 const TrashPageList = (props) => {
 const TrashPageList = (props) => {
@@ -20,9 +23,13 @@ const TrashPageList = (props) => {
     };
     };
   }, [t]);
   }, [t]);
 
 
+  const emptyTrashButton = useMemo(() => {
+    return <EmptyTrashButton />;
+  }, [t]);
+
   return (
   return (
     <div data-testid="trash-page-list" className="mt-5 d-edit-none">
     <div data-testid="trash-page-list" className="mt-5 d-edit-none">
-      <CustomNavAndContents navTabMapping={navTabMapping} />
+      <CustomNavAndContents navTabMapping={navTabMapping} navRightElement={emptyTrashButton} />
     </div>
     </div>
   );
   );
 };
 };

+ 5 - 2
packages/app/src/stores/modal.tsx

@@ -1,11 +1,13 @@
 import { SWRResponse } from 'swr';
 import { SWRResponse } from 'swr';
-import { useStaticSWR } from './use-static-swr';
+
+import { IPageToDeleteWithMeta, IPageToRenameWithMeta } from '~/interfaces/page';
 import {
 import {
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction,
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction,
 } from '~/interfaces/ui';
 } from '~/interfaces/ui';
-import { IPageToDeleteWithMeta, IPageToRenameWithMeta } from '~/interfaces/page';
 import { IUserGroupHasId } from '~/interfaces/user';
 import { IUserGroupHasId } from '~/interfaces/user';
 
 
+import { useStaticSWR } from './use-static-swr';
+
 
 
 /*
 /*
 * PageCreateModal
 * PageCreateModal
@@ -33,6 +35,7 @@ export const usePageCreateModal = (status?: CreateModalStatus): SWRResponse<Crea
 
 
 export type IDeleteModalOption = {
 export type IDeleteModalOption = {
   onDeleted?: OnDeletedFunction,
   onDeleted?: OnDeletedFunction,
+  emptyTrash?: true,
 }
 }
 
 
 type DeleteModalStatus = {
 type DeleteModalStatus = {