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

Merge branch 'imprv/107549-admin-page-related-to-id' of https://github.com/weseek/growi into imprv/107549-admin-page-related-to-id

kaori 3 лет назад
Родитель
Сommit
90455d3ab5

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

@@ -1,55 +1,28 @@
-import React, { FC, useCallback } from 'react';
+import React, { useCallback } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
-import { toastSuccess } from '~/client/util/apiNotification';
-import {
-  IDataWithMeta,
-  IPageHasId,
-  IPageInfo,
-} from '~/interfaces/page';
-import { useEmptyTrashModal } from '~/stores/modal';
-import { useSWRxDescendantsPageListForCurrrentPath, useSWRxPageInfoForList } from '~/stores/page-listing';
+type EmptyTrashButtonProps = {
+  onEmptyTrashButtonClick: () => void,
+  disableEmptyButton: boolean
+};
 
 
 
 
-const EmptyTrashButton: FC = () => {
+const EmptyTrashButton = (props: EmptyTrashButtonProps): JSX.Element => {
+  const { onEmptyTrashButtonClick, disableEmptyButton } = props;
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const { open: openEmptyTrashModal } = useEmptyTrashModal();
-  const { data: pagingResult, mutate } = useSWRxDescendantsPageListForCurrrentPath();
-
-  const pageIds = pagingResult?.items?.map(page => page._id);
-  const { injectTo } = useSWRxPageInfoForList(pageIds, null, 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 deletablePages = pageWithMetas.filter(page => page.meta?.isAbleToDeleteCompletely);
-
-  const onEmptiedTrashHandler = useCallback(() => {
-    toastSuccess(t('empty_trash'));
-
-    mutate();
-  }, [t, mutate]);
 
 
-  const emptyTrashClickHandler = () => {
-    openEmptyTrashModal(deletablePages, { onEmptiedTrash: onEmptiedTrashHandler, canDelepeAllPages: pagingResult?.totalCount === deletablePages.length });
-  };
+  const emptyTrashButtonHandler = useCallback(() => {
+    onEmptyTrashButtonClick();
+  }, [onEmptyTrashButtonClick]);
 
 
   return (
   return (
     <div className="d-flex align-items-center">
     <div className="d-flex align-items-center">
       <button
       <button
         type="button"
         type="button"
         className="btn btn-outline-secondary rounded-pill text-danger d-flex align-items-center"
         className="btn btn-outline-secondary rounded-pill text-danger d-flex align-items-center"
-        disabled={deletablePages.length === 0}
-        onClick={() => emptyTrashClickHandler()}
+        disabled={disableEmptyButton}
+        onClick={emptyTrashButtonHandler}
       >
       >
         <i className="icon-fw icon-trash"></i>
         <i className="icon-fw icon-trash"></i>
         <div>{t('modal_empty.empty_the_trash')}</div>
         <div>{t('modal_empty.empty_the_trash')}</div>

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

@@ -19,7 +19,7 @@ const EmptyTrashModal: FC = () => {
 
 
   const isOpened = emptyTrashModalData?.isOpened ?? false;
   const isOpened = emptyTrashModalData?.isOpened ?? false;
 
 
-  const canDeleteAllpages = emptyTrashModalData?.opts?.canDelepeAllPages ?? false;
+  const canDeleteAllpages = emptyTrashModalData?.opts?.canDeleteAllPages ?? false;
 
 
   const [errs, setErrs] = useState<Error[] | null>(null);
   const [errs, setErrs] = useState<Error[] | null>(null);
 
 

+ 6 - 2
packages/app/src/components/PageAttachment.tsx

@@ -5,7 +5,8 @@ import React, {
 import { HasObjectId, IAttachment } from '@growi/core';
 import { HasObjectId, IAttachment } from '@growi/core';
 
 
 import { useSWRxAttachments } from '~/stores/attachment';
 import { useSWRxAttachments } from '~/stores/attachment';
-import { useEditingMarkdown, useCurrentPageId, useIsGuestUser } from '~/stores/context';
+import { useCurrentPageId, useIsGuestUser } from '~/stores/context';
+import { useSWRxCurrentPage } from '~/stores/page';
 
 
 import { DeleteAttachmentModal } from './PageAttachment/DeleteAttachmentModal';
 import { DeleteAttachmentModal } from './PageAttachment/DeleteAttachmentModal';
 import { PageAttachmentList } from './PageAttachment/PageAttachmentList';
 import { PageAttachmentList } from './PageAttachment/PageAttachmentList';
@@ -17,10 +18,13 @@ const checkIfFileInUse = (markdown: string, attachment): boolean => {
 };
 };
 
 
 const PageAttachment = (): JSX.Element => {
 const PageAttachment = (): JSX.Element => {
+
+  const { data: currentPage } = useSWRxCurrentPage();
+  const markdown = currentPage?.revision.body;
+
   // Static SWRs
   // Static SWRs
   const { data: pageId } = useCurrentPageId();
   const { data: pageId } = useCurrentPageId();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
-  const { data: markdown } = useEditingMarkdown();
 
 
   // States
   // States
   const [pageNumber, setPageNumber] = useState(1);
   const [pageNumber, setPageNumber] = useState(1);

+ 11 - 15
packages/app/src/components/PageAttachment/PageAttachmentList.tsx

@@ -1,11 +1,9 @@
 import React from 'react';
 import React from 'react';
 
 
-
 import { HasObjectId, IAttachment } from '@growi/core';
 import { HasObjectId, IAttachment } from '@growi/core';
 import { Attachment } from '@growi/ui';
 import { Attachment } from '@growi/ui';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
-
 type Props = {
 type Props = {
   attachments: (IAttachment & HasObjectId)[],
   attachments: (IAttachment & HasObjectId)[],
   inUse: { [id: string]: boolean },
   inUse: { [id: string]: boolean },
@@ -24,22 +22,20 @@ export const PageAttachmentList = (props: Props): JSX.Element => {
     return <>{t('No_attachments_yet')}</>;
     return <>{t('No_attachments_yet')}</>;
   }
   }
 
 
-  const attachmentList = attachments.map((attachment) => {
-    return (
-      <Attachment
-        key={`page:attachment:${attachment._id}`}
-        attachment={attachment}
-        inUse={inUse[attachment._id] || false}
-        onAttachmentDeleteClicked={onAttachmentDeleteClicked}
-        isUserLoggedIn={isUserLoggedIn}
-      />
-    );
-  });
-
   return (
   return (
     <div>
     <div>
       <ul className="pl-2">
       <ul className="pl-2">
-        {attachmentList}
+        {attachments.map((attachment) => {
+          return (
+            <Attachment
+              key={`page:attachment:${attachment._id}`}
+              attachment={attachment}
+              inUse={inUse[attachment._id] || false}
+              onAttachmentDeleteClicked={onAttachmentDeleteClicked}
+              isUserLoggedIn={isUserLoggedIn}
+            />
+          );
+        })}
       </ul>
       </ul>
     </div>
     </div>
   );
   );

+ 3 - 4
packages/app/src/components/PageEditor.tsx

@@ -15,8 +15,8 @@ import { apiGet, apiPostForm } from '~/client/util/apiv1-client';
 import { getOptionsToSave } from '~/client/util/editor';
 import { getOptionsToSave } from '~/client/util/editor';
 import { IEditorMethods } from '~/interfaces/editor-methods';
 import { IEditorMethods } from '~/interfaces/editor-methods';
 import {
 import {
-  useCurrentPagePath, useCurrentPathname, useCurrentPageId, useEditingMarkdown,
-  useIsEditable, useIsIndentSizeForced, useIsUploadableFile, useIsUploadableImage,
+  useCurrentPagePath, useCurrentPathname, useCurrentPageId,
+  useIsEditable, useIsIndentSizeForced, useIsUploadableFile, useIsUploadableImage, useEditingMarkdown,
 } from '~/stores/context';
 } from '~/stores/context';
 import {
 import {
   useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
   useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
@@ -55,10 +55,9 @@ const PageEditor = React.memo((): JSX.Element => {
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
   const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
-  const { data: editingMarkdown } = useEditingMarkdown();
   const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
   const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
   const { data: pageTags } = usePageTagsForEditors(pageId);
   const { data: pageTags } = usePageTagsForEditors(pageId);
-
+  const { data: editingMarkdown } = useEditingMarkdown();
   const { data: isEditable } = useIsEditable();
   const { data: isEditable } = useIsEditable();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: isMobile } = useIsMobile();
   const { data: isMobile } = useIsMobile();

+ 53 - 5
packages/app/src/components/TrashPageList.tsx

@@ -1,15 +1,67 @@
-import React, { FC, useMemo } from 'react';
+import React, { FC, useMemo, useCallback } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
+import { toastSuccess } from '~/client/util/apiNotification';
+import {
+  IPageHasId,
+} from '~/interfaces/page';
+import { IPagingResult } from '~/interfaces/paging-result';
+import { useShowPageLimitationXL } from '~/stores/context';
+import { useEmptyTrashModal } from '~/stores/modal';
+import { useSWRxDescendantsPageListForCurrrentPath, useSWRxPageInfoForList } from '~/stores/page-listing';
+
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
 import { DescendantsPageListForCurrentPath } from './DescendantsPageList';
 import { DescendantsPageListForCurrentPath } from './DescendantsPageList';
 import EmptyTrashButton from './EmptyTrashButton';
 import EmptyTrashButton from './EmptyTrashButton';
 import PageListIcon from './Icons/PageListIcon';
 import PageListIcon from './Icons/PageListIcon';
 
 
+const convertToIDataWithMeta = (page) => {
+  return { data: page };
+};
+
+const useEmptyTrashButton = () => {
+
+  const { data: limit } = useShowPageLimitationXL();
+  const { data: pagingResult, mutate: mutatePageLists } = useSWRxDescendantsPageListForCurrrentPath(1, limit);
+  const { t } = useTranslation();
+  const { open: openEmptyTrashModal } = useEmptyTrashModal();
+
+  const pageIds = pagingResult?.items?.map(page => page._id);
+  const { injectTo } = useSWRxPageInfoForList(pageIds, null, true, true);
+
+  const calculateDeletablePages = useCallback((pagingResult?: IPagingResult<IPageHasId>) => {
+    if (pagingResult == null) { return undefined }
+
+    const dataWithMetas = pagingResult.items.map(page => convertToIDataWithMeta(page));
+    const pageWithMetas = injectTo(dataWithMetas);
+
+    return pageWithMetas.filter(page => page.meta?.isAbleToDeleteCompletely);
+  }, [injectTo]);
+
+  const deletablePages = calculateDeletablePages(pagingResult);
+
+  const onEmptiedTrashHandler = useCallback(() => {
+    toastSuccess(t('empty_trash'));
+
+    mutatePageLists();
+  }, [t, mutatePageLists]);
+
+  const emptyTrashClickHandler = useCallback(() => {
+    if (deletablePages == null) { return }
+    openEmptyTrashModal(deletablePages, { onEmptiedTrash: onEmptiedTrashHandler, canDeleteAllPages: pagingResult?.totalCount === deletablePages.length });
+  }, [deletablePages, onEmptiedTrashHandler, openEmptyTrashModal, pagingResult?.totalCount]);
+
+  const emptyTrashButton = useMemo(() => {
+    return <EmptyTrashButton onEmptyTrashButtonClick={emptyTrashClickHandler} disableEmptyButton={deletablePages?.length === 0} />;
+  }, [emptyTrashClickHandler, deletablePages?.length]);
+
+  return emptyTrashButton;
+};
 
 
 export const TrashPageList: FC = () => {
 export const TrashPageList: FC = () => {
   const { t } = useTranslation();
   const { t } = useTranslation();
+  const emptyTrashButton = useEmptyTrashButton();
 
 
   const navTabMapping = useMemo(() => {
   const navTabMapping = useMemo(() => {
     return {
     return {
@@ -22,10 +74,6 @@ export const TrashPageList: FC = () => {
     };
     };
   }, [t]);
   }, [t]);
 
 
-  const emptyTrashButton = useMemo(() => {
-    return <EmptyTrashButton />;
-  }, []);
-
   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} navRightElement={emptyTrashButton} />
       <CustomNavAndContents navTabMapping={navTabMapping} navRightElement={emptyTrashButton} />

+ 1 - 0
packages/app/src/pages/[[...path]].page.tsx

@@ -247,6 +247,7 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
 
 
   useSWRxCurrentPage(undefined, pageWithMeta?.data ?? null); // store initial data
   useSWRxCurrentPage(undefined, pageWithMeta?.data ?? null); // store initial data
   useEditingMarkdown(pageWithMeta?.data.revision?.body ?? '');
   useEditingMarkdown(pageWithMeta?.data.revision?.body ?? '');
+
   const { data: dataPageInfo } = useSWRxPageInfo(pageId);
   const { data: dataPageInfo } = useSWRxPageInfo(pageId);
   const { data: grantData } = useSWRxIsGrantNormalized(pageId);
   const { data: grantData } = useSWRxIsGrantNormalized(pageId);
   const { mutate: mutateSelectedGrant } = useSelectedGrant();
   const { mutate: mutateSelectedGrant } = useSelectedGrant();

+ 7 - 5
packages/app/src/server/routes/apiv3/pages.js

@@ -538,23 +538,25 @@ module.exports = (crowi) => {
         return res.apiv3Err(new ErrorV3('Someone could update this page, so couldn\'t delete.', 'notfound_or_forbidden'), 409);
         return res.apiv3Err(new ErrorV3('Someone could update this page, so couldn\'t delete.', 'notfound_or_forbidden'), 409);
       }
       }
       renamedPage = await crowi.pageService.renamePage(page, newPagePath, req.user, options, activityParameters);
       renamedPage = await crowi.pageService.renamePage(page, newPagePath, req.user, options, activityParameters);
+
+      // Respond before sending notification
+      const result = { page: serializePageSecurely(renamedPage ?? page) };
+      res.apiv3(result);
     }
     }
     catch (err) {
     catch (err) {
       logger.error(err);
       logger.error(err);
       return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
       return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
     }
     }
-    const result = { page: serializePageSecurely(renamedPage ?? page) };
+
     try {
     try {
       // global notification
       // global notification
-      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_MOVE, page, req.user, {
-        oldPath: req.body.path,
+      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_MOVE, renamedPage, req.user, {
+        oldPath: page.path,
       });
       });
     }
     }
     catch (err) {
     catch (err) {
       logger.error('Move notification failed', err);
       logger.error('Move notification failed', err);
     }
     }
-
-    return res.apiv3(result);
   });
   });
 
 
   router.post('/resume-rename', accessTokenParser, loginRequiredStrictly, validator.resumeRenamePage, apiV3FormValidator, async(req, res) => {
   router.post('/resume-rename', accessTokenParser, loginRequiredStrictly, validator.resumeRenamePage, apiV3FormValidator, async(req, res) => {

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

@@ -78,7 +78,7 @@ export const usePageDeleteModal = (status?: DeleteModalStatus): SWRResponse<Dele
 */
 */
 type IEmptyTrashModalOption = {
 type IEmptyTrashModalOption = {
   onEmptiedTrash?: () => void,
   onEmptiedTrash?: () => void,
-  canDelepeAllPages: boolean,
+  canDeleteAllPages: boolean,
 }
 }
 
 
 type EmptyTrashModalStatus = {
 type EmptyTrashModalStatus = {

+ 4 - 0
packages/core/src/interfaces/attachment.ts

@@ -8,4 +8,8 @@ export type IAttachment = {
 
 
   // virtual property
   // virtual property
   filePathProxied: string,
   filePathProxied: string,
+
+  fileFormat: string,
+  downloadPathProxied: string,
+  originalName: string,
 };
 };

+ 0 - 86
packages/ui/src/components/Attachment/Attachment.jsx

@@ -1,86 +0,0 @@
-import React from 'react';
-
-import PropTypes from 'prop-types';
-
-
-import { UserPicture } from '../User/UserPicture';
-
-export class Attachment extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this._onAttachmentDeleteClicked = this._onAttachmentDeleteClicked.bind(this);
-  }
-
-  iconNameByFormat(format) {
-    if (format.match(/image\/.+/i)) {
-      return 'icon-picture';
-    }
-
-    return 'icon-doc';
-  }
-
-  _onAttachmentDeleteClicked(event) {
-    if (this.props.onAttachmentDeleteClicked != null) {
-      this.props.onAttachmentDeleteClicked(this.props.attachment);
-    }
-  }
-
-  render() {
-    const attachment = this.props.attachment;
-    const formatIcon = this.iconNameByFormat(attachment.fileFormat);
-
-    let fileInUse = '';
-    if (this.props.inUse) {
-      fileInUse = <span className="attachment-in-use badge badge-pill badge-info">In Use</span>;
-    }
-
-    const fileType = <span className="attachment-filetype badge badge-pill badge-secondary">{attachment.fileFormat}</span>;
-
-    const btnDownload = (this.props.isUserLoggedIn)
-      ? (
-        <a className="attachment-download" href={attachment.downloadPathProxied}>
-          <i className="icon-cloud-download" />
-        </a>
-      )
-      : '';
-
-    const btnTrash = (this.props.isUserLoggedIn)
-      ? (
-        /* eslint-disable-next-line */
-        <a className="text-danger attachment-delete" onClick={this._onAttachmentDeleteClicked}>
-          <i className="icon-trash" />
-        </a>
-      )
-      : '';
-
-    return (
-      <div className="attachment mb-2">
-        <span className="mr-1 attachment-userpicture">
-          <UserPicture user={attachment.creator} size="sm"></UserPicture>
-        </span>
-        <a className="mr-2" href={attachment.filePathProxied} target="_blank" rel="noopener noreferrer">
-          <i className={formatIcon}></i> {attachment.originalName}
-        </a>
-        <span className="mr-2">{fileType}</span>
-        <span className="mr-2">{fileInUse}</span>
-        <span className="mr-2">{btnDownload}</span>
-        <span className="mr-2">{btnTrash}</span>
-      </div>
-    );
-  }
-
-}
-
-Attachment.propTypes = {
-  attachment: PropTypes.object.isRequired,
-  inUse: PropTypes.bool,
-  onAttachmentDeleteClicked: PropTypes.func,
-  isUserLoggedIn: PropTypes.bool,
-};
-
-Attachment.defaultProps = {
-  inUse: false,
-  isUserLoggedIn: false,
-};

+ 58 - 0
packages/ui/src/components/Attachment/Attachment.tsx

@@ -0,0 +1,58 @@
+import React from 'react';
+
+import { HasObjectId, IAttachment } from '@growi/core';
+
+import { UserPicture } from '../User/UserPicture';
+
+type AttachmentProps = {
+  attachment: IAttachment & HasObjectId,
+  inUse: boolean,
+  onAttachmentDeleteClicked?: (attachment: IAttachment & HasObjectId) => void,
+  isUserLoggedIn?: boolean,
+};
+
+export const Attachment = (props: AttachmentProps): JSX.Element => {
+
+  const {
+    attachment, inUse, isUserLoggedIn, onAttachmentDeleteClicked,
+  } = props;
+
+  const _onAttachmentDeleteClicked = () => {
+    if (onAttachmentDeleteClicked != null) {
+      onAttachmentDeleteClicked(attachment);
+    }
+  };
+
+  const formatIcon = (attachment.fileFormat.match(/image\/.+/i)) ? 'icon-picture' : 'icon-doc';
+  const btnDownload = (isUserLoggedIn)
+    ? (
+      <a className="attachment-download" href={attachment.downloadPathProxied}>
+        <i className="icon-cloud-download" />
+      </a>
+    )
+    : '';
+  const btnTrash = (isUserLoggedIn)
+    ? (
+      <a className="text-danger attachment-delete" onClick={_onAttachmentDeleteClicked}>
+        <i className="icon-trash" />
+      </a>
+    )
+    : '';
+  const fileType = <span className="attachment-filetype badge badge-pill badge-secondary">{attachment.fileFormat}</span>;
+  const fileInUse = (inUse) ? <span className="attachment-in-use badge badge-pill badge-info">In Use</span> : '';
+
+  return (
+    <div className="attachment mb-2">
+      <span className="mr-1 attachment-userpicture">
+        <UserPicture user={attachment.creator} size="sm"></UserPicture>
+      </span>
+      <a className="mr-2" href={attachment.filePathProxied} target="_blank" rel="noopener noreferrer">
+        <i className={formatIcon}></i> {attachment.originalName}
+      </a>
+      <span className="mr-2">{fileType}</span>
+      <span className="mr-2">{fileInUse}</span>
+      <span className="mr-2">{btnDownload}</span>
+      <span className="mr-2">{btnTrash}</span>
+    </div>
+  );
+};