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

Merge pull request #7485 from weseek/feat/rich-attachment-filename

feat: Add delete attachment modal
Yuki Takei 3 лет назад
Родитель
Сommit
f77dff39ff

+ 90 - 0
packages/app/src/components/PageAttachment/AttachmentDeleteModal.tsx

@@ -0,0 +1,90 @@
+import React, { useCallback, useMemo } from 'react';
+
+import { HasObjectId, IAttachment, IUser } from '@growi/core';
+import { UserPicture } from '@growi/ui';
+import {
+  Button, Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+import { Username } from '../User/Username';
+
+import styles from './DeleteAttachmentModal.module.scss';
+
+const iconByFormat = (format: string): string => {
+  return format.match(/image\/.+/i) ? 'icon-picture' : 'icon-doc';
+};
+
+export const AttachmentDeleteModal: React.FC<{
+  isOpen: boolean,
+  toggle: () => void,
+  attachmentToDelete: IAttachment & HasObjectId,
+  deleting: boolean,
+  deleteError: string,
+  onAttachmentDeleteHandler: (attachment: IAttachment & HasObjectId) => Promise<void>,
+}> = ({
+  isOpen, toggle,
+  attachmentToDelete, deleting, deleteError,
+  onAttachmentDeleteHandler,
+}) => {
+
+  const onDeleteClicked = useCallback(() => {
+    onAttachmentDeleteHandler(attachmentToDelete);
+  }, [attachmentToDelete, onAttachmentDeleteHandler]);
+
+  const renderByFileFormat = useCallback((attachment: IAttachment & HasObjectId) => {
+    const content = (attachment.fileFormat.match(/image\/.+/i))
+      // eslint-disable-next-line @next/next/no-img-element
+      ? <img src={attachment.filePathProxied} alt="deleting image" />
+      : '';
+
+    return (
+      <div className="attachment-delete-image">
+        <p>
+          <i className={iconByFormat(attachment.fileFormat)}></i> {attachment.originalName}
+        </p>
+        <p>
+          uploaded by <UserPicture user={attachment.creator} size="sm"></UserPicture> <Username user={attachment.creator as IUser}></Username>
+        </p>
+        {content}
+      </div>
+    );
+  }, []);
+
+  const deletingIndicator = useMemo(() => {
+    if (deleting) {
+      return <div className="speeding-wheel-sm"></div>;
+    }
+    if (deleteError) {
+      return <span>{deleteError}</span>;
+    }
+    return <></>;
+  }, [deleting, deleteError]);
+
+  return (
+    <Modal
+      isOpen={isOpen}
+      className={`${styles['attachment-delete-modal']} attachment-delete-modal`}
+      size="lg"
+      aria-labelledby="contained-modal-title-lg"
+      fade={false}
+    >
+      <ModalHeader tag="h4" toggle={toggle} className="bg-danger text-light">
+        <span id="contained-modal-title-lg">Delete attachment?</span>
+      </ModalHeader>
+      <ModalBody>
+        {renderByFileFormat(attachmentToDelete)}
+      </ModalBody>
+      <ModalFooter>
+        <div className="mr-3 d-inline-block">
+          {deletingIndicator}
+        </div>
+        <Button
+          color="danger"
+          onClick={onDeleteClicked}
+          disabled={deleting}
+        >Delete!
+        </Button>
+      </ModalFooter>
+    </Modal>
+  );
+};

+ 72 - 38
packages/app/src/components/ReactMarkdownComponents/Attachment.tsx

@@ -1,10 +1,17 @@
-import React, { useMemo, useCallback } from 'react';
+import React, { useMemo, useCallback, useState } from 'react';
 
 
+import { HasObjectId, IAttachment } from '@growi/core';
 import { UserPicture } from '@growi/ui';
 import { UserPicture } from '@growi/ui';
 import prettyBytes from 'pretty-bytes';
 import prettyBytes from 'pretty-bytes';
 
 
+import { toastSuccess, toastError } from '~/client/util/toastr';
+import { AttachmentDeleteModal } from '~/components/PageAttachment/AttachmentDeleteModal';
 import { useSWRxAttachments } from '~/stores/attachment';
 import { useSWRxAttachments } from '~/stores/attachment';
+import { useAttachmentDeleteModal } from '~/stores/modal';
 import { useCurrentPageId } from '~/stores/page';
 import { useCurrentPageId } from '~/stores/page';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:attachmentDelete');
 
 
 export const Attachment: React.FC<{
 export const Attachment: React.FC<{
   attachmentId: string,
   attachmentId: string,
@@ -14,70 +21,97 @@ export const Attachment: React.FC<{
   const { data: pageId } = useCurrentPageId();
   const { data: pageId } = useCurrentPageId();
   // TODO: We need to be able to get it from all pages if there are a lot of attachments.
   // TODO: We need to be able to get it from all pages if there are a lot of attachments.
   const { data: dataAttachments, remove } = useSWRxAttachments(pageId, 1);
   const { data: dataAttachments, remove } = useSWRxAttachments(pageId, 1);
+  const { data: attachmentDeleteModal, open: openAttachmentDeleteModal, close: closeAttachmentDeleteModal } = useAttachmentDeleteModal();
+  const [attachmentToDelete, setAttachmentToDelete] = useState<(IAttachment & HasObjectId) | null>(null);
+  const [deleting, setDeleting] = useState<boolean>(false);
+  const [deleteError, setDeleteError] = useState<string>('');
 
 
   const attachment = useMemo(() => {
   const attachment = useMemo(() => {
     if (dataAttachments == null) {
     if (dataAttachments == null) {
       return;
       return;
     }
     }
-
     return dataAttachments.attachments.find(item => item._id === attachmentId);
     return dataAttachments.attachments.find(item => item._id === attachmentId);
   }, [attachmentId, dataAttachments]);
   }, [attachmentId, dataAttachments]);
 
 
-  // TODO: Call delete attachment modal.
-  const handleAttachmentDelete = useCallback(async() => {
-    if (attachment == null) {
-      return;
-    }
+  const onAttachmentDeleteClicked = useCallback((attachment: IAttachment & HasObjectId) => {
+    setAttachmentToDelete(attachment);
+    openAttachmentDeleteModal();
+  }, [openAttachmentDeleteModal]);
+
+  const onToggleHandler = useCallback(() => {
+    setAttachmentToDelete(null);
+    setDeleteError('');
+  }, []);
+
+  const onAttachmentDeleteHandler = useCallback(async(attachment: IAttachment & HasObjectId) => {
+    setDeleting(true);
 
 
     try {
     try {
       await remove({ attachment_id: attachment._id });
       await remove({ attachment_id: attachment._id });
-      // TODO: success notification
+      setAttachmentToDelete(null);
+      setDeleting(false);
+      closeAttachmentDeleteModal();
+      toastSuccess(`Delete ${attachmentName}`);
     }
     }
     catch (err) {
     catch (err) {
-      // TODO: error handling
+      setDeleteError('Something went wrong.');
+      closeAttachmentDeleteModal();
+      toastError(err);
+      logger.error(err);
     }
     }
-  }, [attachment, remove]);
+  }, [attachmentName, closeAttachmentDeleteModal, remove]);
 
 
-  // TODO: update fo attachment is not found
-  // TODO: fix hydration failed error
   if (attachment == null) {
   if (attachment == null) {
     return (
     return (
-      <div className='text-muted'>This attachment not found.</div>
+      <span className='text-muted'>This attachment not found.</span>
     );
     );
   }
   }
 
 
   return (
   return (
-    <div className="card my-3" style={{ width: 'fit-content' }}>
-      <div className="card-body pr-0">
-        <div className='row'>
-          <div className='col-2'>
-            <div className='icon-doc' style={{ fontSize: '2.7rem', opacity: '0.5' }}/>
-          </div>
-          <div className='col-10'>
-            <div>
-              <a className='' href={attachment.downloadPathProxied}>{attachment.originalName}</a>
-              <span className='ml-2'>
-                <a className="attachment-download" href={attachment.downloadPathProxied}>
-                  <i className="icon-cloud-download" />
-                </a>
-              </span>
-              <span className='ml-2'>
-                <a className="text-danger attachment-delete" onClick={handleAttachmentDelete}>
-                  <i className="icon-trash" />
-                </a>
-              </span>
+    <>
+      <div className="card my-3" style={{ width: 'fit-content' }}>
+        <div className="card-body pr-0">
+          <div className='row'>
+            <div className='col-2'>
+              <div className='icon-doc' style={{ fontSize: '2.7rem', opacity: '0.5' }}/>
             </div>
             </div>
-            <div>
-              <UserPicture user={attachment.creator} size="sm"></UserPicture>
-              {/* TODO: check locale */}
-              <span className='ml-2 text-muted'>{new Date(attachment.createdAt).toLocaleString()}</span>
-              <span className='border-left ml-2 pl-2 text-muted'>{prettyBytes(attachment.fileSize)}</span>
+            <div className='col-10'>
+              <div>
+                <a className='' href={attachment.downloadPathProxied}>{attachment.originalName}</a>
+                <span className='ml-2'>
+                  <a className="attachment-download" href={attachment.downloadPathProxied}>
+                    <i className="icon-cloud-download" />
+                  </a>
+                </span>
+                <span className='ml-2'>
+                  <a className="text-danger attachment-delete" onClick={() => onAttachmentDeleteClicked(attachment)}>
+                    <i className="icon-trash" />
+                  </a>
+                </span>
+              </div>
+              <div>
+                <UserPicture user={attachment.creator} size="sm"></UserPicture>
+                {/* TODO: check locale */}
+                <span className='ml-2 text-muted'>{new Date(attachment.createdAt).toLocaleString()}</span>
+                <span className='border-left ml-2 pl-2 text-muted'>{prettyBytes(attachment.fileSize)}</span>
+              </div>
             </div>
             </div>
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>
-    </div>
 
 
+      {/* TODO: move rendering position */}
+      {attachmentToDelete != null && (
+        <AttachmentDeleteModal
+          isOpen={attachmentDeleteModal?.isOpened || false}
+          toggle={onToggleHandler}
+          attachmentToDelete={attachmentToDelete}
+          deleting={deleting}
+          deleteError={deleteError}
+          onAttachmentDeleteHandler={onAttachmentDeleteHandler}
+        />
+      )}
+    </>
   );
   );
 });
 });
 Attachment.displayName = 'Attachment';
 Attachment.displayName = 'Attachment';

+ 1 - 6
packages/app/src/components/User/Username.tsx

@@ -3,12 +3,7 @@ import React from 'react';
 import type { IUser } from '@growi/core';
 import type { IUser } from '@growi/core';
 import Link from 'next/link';
 import Link from 'next/link';
 
 
-type UsernameProps = {
- user?: IUser,
-}
-
-export const Username = (props: UsernameProps): JSX.Element => {
-  const { user } = props;
+export const Username: React.FC<{ user?: IUser }> = ({ user }): JSX.Element => {
 
 
   if (user == null) {
   if (user == null) {
     return <span>anyone</span>;
     return <span>anyone</span>;

+ 26 - 0
packages/app/src/stores/modal.tsx

@@ -607,3 +607,29 @@ export const useTemplateModal = (): SWRResponse<TemplateModalStatus, Error> & Te
     },
     },
   });
   });
 };
 };
+
+/**
+ * AttachmentDeleteModal
+ */
+type AttachmentDeleteModalStatus = {
+  isOpened: boolean,
+}
+
+type AttachmentDeleteModalUtils = {
+  open(): void,
+  close(): void,
+}
+
+export const useAttachmentDeleteModal = (): SWRResponse<AttachmentDeleteModalStatus, Error> & AttachmentDeleteModalUtils => {
+  const initialStatus: AttachmentDeleteModalStatus = { isOpened: false };
+  const swrResponse = useStaticSWR<AttachmentDeleteModalStatus, Error>('attachmentDeleteModal', undefined, { fallbackData: initialStatus });
+
+  return Object.assign(swrResponse, {
+    open: () => {
+      swrResponse.mutate({ isOpened: true });
+    },
+    close: () => {
+      swrResponse.mutate({ isOpened: false });
+    },
+  });
+};