Преглед изворни кода

Merge pull request #6336 from weseek/support/fc-PageAttachment

support: FC/TSize PageAttachment
Haku Mizuki пре 3 година
родитељ
комит
c94a7c09ee

+ 1 - 0
packages/app/src/client/services/PageContainer.js

@@ -135,6 +135,7 @@ export default class PageContainer extends Container {
 
 
   /**
   /**
    * initialize state for markdown data
    * initialize state for markdown data
+   * [Already SWRized]
    */
    */
   initStateMarkdown() {
   initStateMarkdown() {
     let pageContent = '';
     let pageContent = '';

+ 0 - 199
packages/app/src/components/PageAttachment.jsx

@@ -1,199 +0,0 @@
-/* eslint-disable react/no-access-state-in-setstate */
-import React from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-
-import PageContainer from '~/client/services/PageContainer';
-import { apiPost } from '~/client/util/apiv1-client';
-import { apiv3Get } from '~/client/util/apiv3-client';
-import { useIsGuestUser } from '~/stores/context';
-
-import DeleteAttachmentModal from './PageAttachment/DeleteAttachmentModal';
-import PageAttachmentList from './PageAttachment/PageAttachmentList';
-import PaginationWrapper from './PaginationWrapper';
-import { withUnstatedContainers } from './UnstatedUtils';
-
-
-class PageAttachment extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      activePage: 1,
-      totalAttachments: 0,
-      limit: Infinity,
-      attachments: [],
-      inUse: {},
-      attachmentToDelete: null,
-      deleting: false,
-      deleteError: '',
-    };
-
-    this.handlePage = this.handlePage.bind(this);
-    this.onAttachmentDeleteClicked = this.onAttachmentDeleteClicked.bind(this);
-    this.onAttachmentDeleteClickedConfirm = this.onAttachmentDeleteClickedConfirm.bind(this);
-  }
-
-
-  async handlePage(selectedPage) {
-    const { pageId } = this.props.pageContainer.state;
-    const page = selectedPage;
-
-    if (!pageId) { return }
-
-    const res = await apiv3Get('/attachment/list', { pageId, page });
-    const attachments = res.data.paginateResult.docs;
-    const totalAttachments = res.data.paginateResult.totalDocs;
-    const pagingLimit = res.data.paginateResult.limit;
-
-    const inUse = {};
-
-    for (const attachment of attachments) {
-      inUse[attachment._id] = this.checkIfFileInUse(attachment);
-    }
-    this.setState({
-      activePage: selectedPage,
-      totalAttachments,
-      limit: pagingLimit,
-      attachments,
-      inUse,
-    });
-  }
-
-
-  async componentDidMount() {
-    await this.handlePage(1);
-    this.setState({
-      activePage: 1,
-    });
-  }
-
-  checkIfFileInUse(attachment) {
-    const { markdown } = this.props.pageContainer.state;
-
-    if (markdown.match(attachment._id)) {
-      return true;
-    }
-    return false;
-  }
-
-  onAttachmentDeleteClicked(attachment) {
-    this.setState({
-      attachmentToDelete: attachment,
-    });
-  }
-
-  onAttachmentDeleteClickedConfirm(attachment) {
-    const attachmentId = attachment._id;
-    this.setState({
-      deleting: true,
-    });
-
-    apiPost('/attachments.remove', { attachment_id: attachmentId })
-      .then((res) => {
-        this.setState({
-          attachments: this.state.attachments.filter((at) => {
-            // comparing ObjectId
-            // eslint-disable-next-line eqeqeq
-            return at._id != attachmentId;
-          }),
-          attachmentToDelete: null,
-          deleting: false,
-        });
-      }).catch((err) => {
-        this.setState({
-          deleteError: 'Something went wrong.',
-          deleting: false,
-        });
-      });
-  }
-
-
-  render() {
-    const { t, isGuestUser } = this.props;
-
-    if (this.state.attachments.length === 0) {
-      return (
-        <div data-testid="page-attachment">
-          {t('No_attachments_yet')}
-        </div>
-      );
-    }
-
-    let deleteAttachmentModal = '';
-    if (!isGuestUser) {
-      const attachmentToDelete = this.state.attachmentToDelete;
-      const deleteModalClose = () => {
-        this.setState({ attachmentToDelete: null, deleteError: '' });
-      };
-      const showModal = attachmentToDelete !== null;
-
-      let deleteInUse = null;
-      if (attachmentToDelete !== null) {
-        deleteInUse = this.state.inUse[attachmentToDelete._id] || false;
-      }
-
-      deleteAttachmentModal = (
-        <DeleteAttachmentModal
-          isOpen={showModal}
-          animation="false"
-          toggle={deleteModalClose}
-          attachmentToDelete={attachmentToDelete}
-          inUse={deleteInUse}
-          deleting={this.state.deleting}
-          deleteError={this.state.deleteError}
-          onAttachmentDeleteClickedConfirm={this.onAttachmentDeleteClickedConfirm}
-        />
-      );
-    }
-
-    return (
-      <div data-testid="page-attachment">
-        <PageAttachmentList
-          attachments={this.state.attachments}
-          inUse={this.state.inUse}
-          onAttachmentDeleteClicked={this.onAttachmentDeleteClicked}
-          isUserLoggedIn={!isGuestUser}
-        />
-
-        {deleteAttachmentModal}
-
-        <PaginationWrapper
-          activePage={this.state.activePage}
-          changePage={this.handlePage}
-          totalItemsCount={this.state.totalAttachments}
-          pagingLimit={this.state.limit}
-          align="center"
-        />
-      </div>
-    );
-  }
-
-}
-
-PageAttachment.propTypes = {
-  t: PropTypes.func.isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
-  isGuestUser: PropTypes.bool.isRequired,
-};
-
-/**
- * Wrapper component for using unstated
- */
-const PageAttachmentUnstatedWrapper = withUnstatedContainers(PageAttachment, [PageContainer]);
-
-const PageAttachmentWrapper = (props) => {
-  const { t } = useTranslation();
-  const { data: isGuestUser } = useIsGuestUser();
-
-  if (isGuestUser == null) {
-    return <></>;
-  }
-
-  return <PageAttachmentUnstatedWrapper {...props} t={t} isGuestUser={isGuestUser} />;
-};
-
-export default PageAttachmentWrapper;

+ 151 - 0
packages/app/src/components/PageAttachment.tsx

@@ -0,0 +1,151 @@
+import React, { useCallback, useEffect, useState } from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { useSWRxAttachments } from '~/stores/attachment';
+import { useEditingMarkdown, useCurrentPageId, useIsGuestUser } from '~/stores/context';
+
+import DeleteAttachmentModal from './PageAttachment/DeleteAttachmentModal';
+import PageAttachmentList from './PageAttachment/PageAttachmentList';
+import PaginationWrapper from './PaginationWrapper';
+
+// Utility
+const checkIfFileInUse = (markdown: string, attachment) => {
+  return markdown.match(attachment._id);
+};
+
+// Custom hook that handles processes related to inUseAttachments
+const useInUseAttachments = (attachments) => {
+  const { data: markdown } = useEditingMarkdown();
+  const [inUse, setInUse] = useState<any>({});
+
+  // Update inUse when either of attachments or markdown is updated
+  useEffect(() => {
+    if (markdown == null) {
+      return;
+    }
+
+    const newInUse = {};
+
+    for (const attachment of attachments) {
+      newInUse[attachment._id] = checkIfFileInUse(markdown, attachment);
+    }
+
+    setInUse(newInUse);
+  }, [attachments, markdown]);
+
+  return inUse;
+};
+
+const PageAttachment = (): JSX.Element => {
+  const { t } = useTranslation();
+
+  // Static SWRs
+  const { data: pageId } = useCurrentPageId();
+  const { data: isGuestUser } = useIsGuestUser();
+
+  // States
+  const [pageNumber, setPageNumber] = useState(1);
+  const [attachmentToDelete, setAttachmentToDelete] = useState<any>(undefined);
+  const [deleting, setDeleting] = useState(false);
+  const [deleteError, setDeleteError] = useState('');
+
+  // SWRs
+  const { data: dataAttachments, remove } = useSWRxAttachments(pageId, pageNumber);
+  const {
+    attachments = [],
+    totalAttachments = 0,
+    limit,
+  } = dataAttachments ?? {};
+
+  // Custom hooks
+  const inUseAttachments = useInUseAttachments(attachments);
+
+  // Methods
+  const onChangePageHandler = useCallback((newPageNumber: number) => {
+    setPageNumber(newPageNumber);
+  }, []);
+
+  const onAttachmentDeleteClicked = useCallback((attachment) => {
+    setAttachmentToDelete(attachment);
+  }, []);
+
+  const onAttachmentDeleteClickedConfirmHandler = useCallback(async(attachment) => {
+    setDeleting(true);
+
+    try {
+      await remove({ attachment_id: attachment._id });
+
+      setAttachmentToDelete(null);
+      setDeleting(false);
+    }
+    catch {
+      setDeleteError('Something went wrong.');
+      setDeleting(false);
+    }
+  }, [remove]);
+
+  const onToggleHandler = useCallback(() => {
+    setAttachmentToDelete(null);
+    setDeleteError('');
+  }, []);
+
+  // Renderers
+  const renderDeleteAttachmentModal = useCallback(() => {
+    if (isGuestUser) {
+      return <></>;
+    }
+
+    if (attachments.length === 0) {
+      return (
+        <div data-testid="page-attachment">
+          {t('No_attachments_yet')}
+        </div>
+      );
+    }
+
+    let deleteInUse = null;
+    if (attachmentToDelete != null) {
+      deleteInUse = inUseAttachments[attachmentToDelete._id] || false;
+    }
+
+    const isOpen = attachmentToDelete != null;
+
+    return (
+      <DeleteAttachmentModal
+        isOpen={isOpen}
+        animation="false"
+        toggle={onToggleHandler}
+        attachmentToDelete={attachmentToDelete}
+        inUse={deleteInUse}
+        deleting={deleting}
+        deleteError={deleteError}
+        onAttachmentDeleteClickedConfirm={onAttachmentDeleteClickedConfirmHandler}
+      />
+    );
+  // eslint-disable-next-line max-len
+  }, [attachmentToDelete, attachments.length, deleteError, deleting, inUseAttachments, isGuestUser, onAttachmentDeleteClickedConfirmHandler, onToggleHandler, t]);
+
+  return (
+    <div data-testid="page-attachment">
+      <PageAttachmentList
+        attachments={attachments}
+        inUse={inUseAttachments}
+        onAttachmentDeleteClicked={onAttachmentDeleteClicked}
+        isUserLoggedIn={!isGuestUser}
+      />
+
+      {renderDeleteAttachmentModal()}
+
+      <PaginationWrapper
+        activePage={pageNumber}
+        changePage={onChangePageHandler}
+        totalItemsCount={totalAttachments}
+        pagingLimit={limit}
+        align="center"
+      />
+    </div>
+  );
+};
+
+export default PageAttachment;

+ 10 - 1
packages/app/src/interfaces/attachment.ts

@@ -1 +1,10 @@
-export type { IAttachment } from '@growi/core';
+import type { IAttachment } from '@growi/core';
+
+import type { PaginateResult } from './mongoose-utils';
+
+
+export type IResAttachmentList = {
+  data: {
+    paginateResult: PaginateResult<IAttachment>
+  }
+};

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

@@ -58,7 +58,7 @@ import {
   useHackmdUri,
   useHackmdUri,
   useIsAclEnabled, useIsUserPage, useIsNotCreatable,
   useIsAclEnabled, useIsUserPage, useIsNotCreatable,
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useCurrentPageId, useCurrentPathname,
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useCurrentPageId, useCurrentPathname,
-  useIsSlackConfigured, useIsBlinkedHeaderAtBoot, useRendererConfig,
+  useIsSlackConfigured, useIsBlinkedHeaderAtBoot, useRendererConfig, useEditingMarkdown,
 } from '../stores/context';
 } from '../stores/context';
 import { useXss } from '../stores/xss';
 import { useXss } from '../stores/xss';
 
 
@@ -246,6 +246,7 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   useIsNotCreatable(props.isForbidden || !isCreatablePage(pageWithMeta?.data.path ?? '')); // TODO: need to include props.isIdentical
   useIsNotCreatable(props.isForbidden || !isCreatablePage(pageWithMeta?.data.path ?? '')); // TODO: need to include props.isIdentical
   useCurrentPagePath(pageWithMeta?.data.path);
   useCurrentPagePath(pageWithMeta?.data.path);
   useCurrentPathname(props.currentPathname);
   useCurrentPathname(props.currentPathname);
+  useEditingMarkdown(pageWithMeta?.data.revision.body);
 
 
   // sync pathname by Shallow Routing https://nextjs.org/docs/routing/shallow-routing
   // sync pathname by Shallow Routing https://nextjs.org/docs/routing/shallow-routing
   useEffect(() => {
   useEffect(() => {

+ 3 - 3
packages/app/src/server/routes/apiv3/attachment.js

@@ -28,7 +28,7 @@ module.exports = (crowi) => {
   const validator = {
   const validator = {
     retrieveAttachments: [
     retrieveAttachments: [
       query('pageId').isMongoId().withMessage('pageId is required'),
       query('pageId').isMongoId().withMessage('pageId is required'),
-      query('page').optional().isInt().withMessage('page must be a number'),
+      query('pageNumber').optional().isInt().withMessage('pageNumber must be a number'),
       query('limit').optional().isInt({ max: 100 }).withMessage('You should set less than 100 or not to set limit.'),
       query('limit').optional().isInt({ max: 100 }).withMessage('You should set less than 100 or not to set limit.'),
     ],
     ],
   };
   };
@@ -53,8 +53,8 @@ module.exports = (crowi) => {
   router.get('/list', accessTokenParser, loginRequired, validator.retrieveAttachments, apiV3FormValidator, async(req, res) => {
   router.get('/list', accessTokenParser, loginRequired, validator.retrieveAttachments, apiV3FormValidator, async(req, res) => {
 
 
     const limit = req.query.limit || await crowi.configManager.getConfig('crowi', 'customize:showPageLimitationS') || 10;
     const limit = req.query.limit || await crowi.configManager.getConfig('crowi', 'customize:showPageLimitationS') || 10;
-    const page = req.query.page || 1;
-    const offset = (page - 1) * limit;
+    const pageNumber = req.query.pageNumber || 1;
+    const offset = (pageNumber - 1) * limit;
 
 
     try {
     try {
       const pageId = req.query.pageId;
       const pageId = req.query.pageId;

+ 52 - 0
packages/app/src/stores/attachment.tsx

@@ -0,0 +1,52 @@
+import { useCallback } from 'react';
+
+import {
+  IAttachment, Nullable, SWRResponseWithUtils, withUtils,
+} from '@growi/core';
+import useSWR from 'swr';
+
+import { apiGet, apiPost } from '~/client/util/apiv1-client';
+import { IResAttachmentList } from '~/interfaces/attachment';
+
+type Util = {
+  remove(body: { attachment_id: string }): Promise<void>
+};
+
+type IDataAttachmentList = {
+  attachments: IAttachment[]
+  totalAttachments: number
+  limit: number
+};
+
+export const useSWRxAttachments = (pageId?: Nullable<string>, pageNumber?: number): SWRResponseWithUtils<Util, IDataAttachmentList, Error> => {
+  const shouldFetch = pageId != null && pageNumber != null;
+
+  const fetcher = useCallback(async(endpoint) => {
+    const res = await apiGet<IResAttachmentList>(endpoint, { pageId, pageNumber });
+    return {
+      attachments: res.data.paginateResult.docs,
+      totalAttachments: res.data.paginateResult.totalDocs,
+      limit: res.data.paginateResult.limit,
+    };
+  }, [pageId, pageNumber]);
+
+  const swrResponse = useSWR(
+    shouldFetch ? ['/attachments/list', pageId, pageNumber] : null,
+    fetcher,
+  );
+
+  // Utils
+  const remove = useCallback(async(body: { attachment_id: string }) => {
+    const { mutate } = swrResponse;
+
+    try {
+      await apiPost('/attachments.remove', body);
+      mutate();
+    }
+    catch (err) {
+      throw err;
+    }
+  }, [swrResponse]);
+
+  return withUtils<Util, IDataAttachmentList, Error>(swrResponse, { remove });
+};

+ 4 - 0
packages/app/src/stores/context.tsx

@@ -232,6 +232,10 @@ export const useIsBlinkedHeaderAtBoot = (initialData?: boolean): SWRResponse<boo
   return useStaticSWR('isBlinkedAtBoot', initialData);
   return useStaticSWR('isBlinkedAtBoot', initialData);
 };
 };
 
 
+export const useEditingMarkdown = (initialData?: string): SWRResponse<string, Error> => {
+  return useStaticSWR('currentMarkdown', initialData);
+};
+
 
 
 /** **********************************************************
 /** **********************************************************
  *                     Computed contexts
  *                     Computed contexts

+ 1 - 0
packages/core/src/index.ts

@@ -27,3 +27,4 @@ export * from './service/localstorage-manager';
 export * from './utils/basic-interceptor';
 export * from './utils/basic-interceptor';
 export * from './utils/browser-utils';
 export * from './utils/browser-utils';
 export * from './utils/mongoose-utils';
 export * from './utils/mongoose-utils';
+export * from './utils/with-utils';