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

Update infinite scroll on revision table

https://youtrack.weseek.co.jp/issue/GW-7908
- Update InfiniteScroll props
- Remove unused variable and methods from PageHistory component
- Remove InfiniteScroll from PageRevisionTable
- Add scroll event listener for revision table
- Update endpoint condition of useSWRxInfinitePageRevisions
- Define type of useSWRxInfinitePageRevisions response
- Adjust return type of revision list route
- Change page query to offset in revision list route
- Update props type of Revision and RevisionComparer component
Mudana-Grune 3 лет назад
Родитель
Сommit
ebfd863f2f

+ 7 - 12
packages/app/src/components/InfiniteScroll.tsx

@@ -11,7 +11,6 @@ type Props<T> = {
   endingIndicator?: React.ReactNode
   isReachingEnd?: boolean
   offset?: number
-  isLoadingIndicatorShown?: boolean
 }
 
 const useIntersection = <E extends HTMLElement>(): [boolean, Ref<E>] => {
@@ -48,7 +47,6 @@ const InfiniteScroll = <E, >(props: Props<E>): React.ReactElement<Props<E>> => {
     endingIndicator,
     isReachingEnd,
     offset = 0,
-    isLoadingIndicatorShown = true,
   } = props;
 
   const [intersecting, ref] = useIntersection<HTMLDivElement>();
@@ -62,16 +60,13 @@ const InfiniteScroll = <E, >(props: Props<E>): React.ReactElement<Props<E>> => {
   return (
     <>
       {children}
-
-      { isLoadingIndicatorShown && (
-        <div style={{ position: 'relative' }}>
-          <div ref={ref} style={{ position: 'absolute', top: offset }}></div>
-          {isReachingEnd
-            ? endingIndicator
-            : loadingIndicator || <LoadingIndicator />
-          }
-        </div>
-      )}
+      <div style={{ position: 'relative' }}>
+        <div ref={ref} style={{ position: 'absolute', top: offset }}></div>
+        {isReachingEnd
+          ? endingIndicator
+          : loadingIndicator || <LoadingIndicator />
+        }
+      </div>
     </>
   );
 };

+ 1 - 38
packages/app/src/components/PageHistory.tsx

@@ -1,9 +1,5 @@
-import React, { useState, useEffect } from 'react';
+import React from 'react';
 
-import { IRevisionHasPageId } from '@growi/core';
-
-import { useCurrentPageId } from '~/stores/context';
-import { useCurrentPagePath, useSWRxInfinitePageRevisions } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 
 import { PageRevisionTable } from './PageHistory/PageRevisionTable';
@@ -11,39 +7,6 @@ import { PageRevisionTable } from './PageHistory/PageRevisionTable';
 const logger = loggerFactory('growi:PageHistory');
 
 export const PageHistory: React.FC<{ onClose: () => void }> = ({ onClose }) => {
-
-  const { data: currentPageId } = useCurrentPageId();
-  const { data: currentPagePath } = useCurrentPagePath();
-
-  const swrInifiniteResponse = useSWRxInfinitePageRevisions(currentPageId);
-  const { data: revisionsData, mutate: mutatePageRevisions } = swrInifiniteResponse;
-
-
-  const [sourceRevision, setSourceRevision] = useState<IRevisionHasPageId>();
-  const [targetRevision, setTargetRevision] = useState<IRevisionHasPageId>();
-
-  const latestRevision = revisionsData != null ? revisionsData[0][0] : null;
-
-  useEffect(() => {
-    if (latestRevision != null) {
-      setSourceRevision(latestRevision);
-      setTargetRevision(latestRevision);
-    }
-  }, [latestRevision]);
-
-  useEffect(() => {
-    mutatePageRevisions();
-  });
-
-
-  if (revisionsData == null || sourceRevision == null || targetRevision == null || currentPageId == null || currentPagePath == null) {
-    return (
-      <div className="text-muted text-center">
-        <i className="fa fa-2x fa-spinner fa-pulse mt-3"></i>
-      </div>
-    );
-  }
-
   return (
     <div className="revision-history" data-testid="page-history">
       <PageRevisionTable

+ 69 - 50
packages/app/src/components/PageHistory/PageRevisionTable.tsx

@@ -1,25 +1,24 @@
 import React, {
-  useEffect, useState,
+  useEffect, useRef, useState,
 } from 'react';
 
 import { IRevisionHasId, IRevisionHasPageId } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 
 import { useCurrentPageId } from '~/stores/context';
-import { useSWRxInfinitePageRevisions } from '~/stores/page';
+import { useCurrentPagePath, useSWRxInfinitePageRevisions } from '~/stores/page';
 
-import InfiniteScroll from '../InfiniteScroll';
 import { RevisionComparer } from '../RevisionComparer/RevisionComparer';
 
 import { Revision } from './Revision';
 
 import styles from './PageRevisionTable.module.scss';
 
-type PageRevisionTAble = {
+type PageRevisionTableProps = {
   onClose: () => void,
 }
 
-export const PageRevisionTable = (props: PageRevisionTAble): JSX.Element => {
+export const PageRevisionTable = (props: PageRevisionTableProps): JSX.Element => {
   const { t } = useTranslation();
 
   const REVISIONS_PER_PAGE = 10;
@@ -29,21 +28,26 @@ export const PageRevisionTable = (props: PageRevisionTAble): JSX.Element => {
   } = props;
 
   const { data: currentPageId } = useCurrentPageId();
-  const swrInifiniteResponse = useSWRxInfinitePageRevisions(currentPageId);
-
-  const { data: revisionsData, isLoading } = swrInifiniteResponse;
-  const revisions = revisionsData && revisionsData[0];
-  const oldestRevision = revisions && revisions[revisions.length - 1];
+  const { data: currentPagePath } = useCurrentPagePath();
+  const swrInifiniteResponse = useSWRxInfinitePageRevisions(currentPageId, REVISIONS_PER_PAGE);
 
+  const {
+    data, size, error, setSize, isValidating,
+  } = swrInifiniteResponse;
 
-  const isEmpty = revisionsData?.[0].length === 0;
-  const isReachingEnd = isEmpty
-   || (revisionsData && revisionsData[revisionsData.length - 1]?.length < REVISIONS_PER_PAGE);
+  const revisions = data && data[0].revisions;
+  const oldestRevision = revisions && revisions[revisions.length - 1];
 
+  // First load
+  const isLoadingInitialData = !data && !error;
+  const isLoadingMore = isLoadingInitialData
+    || (isValidating && data && typeof data[size - 1] === 'undefined');
+  const isReachingEnd = !!(data && data[data.length - 1]?.revisions.length < REVISIONS_PER_PAGE);
 
   const [sourceRevision, setSourceRevision] = useState<IRevisionHasPageId>();
   const [targetRevision, setTargetRevision] = useState<IRevisionHasPageId>();
-  const latestRevision = revisionsData != null ? revisionsData[0][0] : null;
+  const latestRevision = data != null ? data[0].revisions[0] : null;
+  const tbodyRef = useRef<HTMLTableSectionElement>(null);
 
 
   useEffect(() => {
@@ -53,6 +57,30 @@ export const PageRevisionTable = (props: PageRevisionTAble): JSX.Element => {
     }
   }, [latestRevision]);
 
+  useEffect(() => {
+    // Apply ref to tbody
+    const tbody = tbodyRef.current;
+    const handleScroll = () => {
+      const offset = 30; // Threshold before scroll actually reaching the end
+      if (tbody) {
+        // Scroll end
+        const isEnd = tbody.scrollTop + tbody.clientHeight + offset >= tbody.scrollHeight;
+        if (isEnd && !isLoadingMore && !isReachingEnd) {
+          setSize(size + 1);
+        }
+      }
+    };
+    if (tbody) {
+      tbody.addEventListener('scroll', handleScroll);
+    }
+    return () => {
+      if (tbody) {
+        tbody.removeEventListener('scroll', handleScroll);
+      }
+    };
+  }, [isLoadingMore, isReachingEnd, setSize, size]);
+
+
   const onChangeSourceInvoked: React.Dispatch<React.SetStateAction<IRevisionHasId | undefined>> = (revision: IRevisionHasPageId) => {
     setSourceRevision(revision);
   };
@@ -84,6 +112,8 @@ export const PageRevisionTable = (props: PageRevisionTAble): JSX.Element => {
               revision={revision}
               isLatestRevision={revision === latestRevision}
               hasDiff={hasDiff}
+              currentPageId={currentPageId}
+              currentPagePath={currentPagePath}
               key={`revision-history-rev-${revisionId}`}
               onClose={onClose}
             />
@@ -148,47 +178,36 @@ export const PageRevisionTable = (props: PageRevisionTAble): JSX.Element => {
 
   return (
     <>
-      { !isLoading ? (
-        <table className={`${styles['revision-history-table']} table revision-history-table`}>
-          <thead>
-            <tr className="d-flex">
-              <th className="col">{t('page_history.revision')}</th>
-              <th className="col-1">{t('page_history.comparing_source')}</th>
-              <th className="col-2">{t('page_history.comparing_target')}</th>
-            </tr>
-          </thead>
-          <tbody className="overflow-auto d-block">
-            {revisions && (
-              <InfiniteScroll
-                swrInifiniteResponse={swrInifiniteResponse}
-                isReachingEnd={isReachingEnd}
-                isLoadingIndicatorShown ={false}
-              >
-                { revisionsData != null && revisionsData.map(apiResult => apiResult).flat()
-                  .map((revision, idx) => {
-                    const previousRevision = (idx + 1 < revisions?.length) ? revisions[idx + 1] : revision;
-
-                    const isOldestRevision = revision === oldestRevision;
-                    const latestRevision = revisions[0];
-
-                    // set 'true' if undefined for backward compatibility
-                    const hasDiff = revision.hasDiffToPrev !== false;
-                    return renderRow(revision, previousRevision, latestRevision, isOldestRevision, hasDiff);
-                  })
-                }
-              </InfiniteScroll>
-            )}
-          </tbody>
-        </table>) : (
-        <div className="text-muted text-center">
-          <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
-        </div>
-      )}
+      <table className={`${styles['revision-history-table']} table revision-history-table`}>
+        <thead>
+          <tr className="d-flex">
+            <th className="col">{t('page_history.revision')}</th>
+            <th className="col-1">{t('page_history.comparing_source')}</th>
+            <th className="col-2">{t('page_history.comparing_target')}</th>
+          </tr>
+        </thead>
+        <tbody className="overflow-auto d-block" ref={tbodyRef}>
+          { revisions && data != null && data.map(apiResult => apiResult.revisions).flat()
+            .map((revision, idx) => {
+              const previousRevision = (idx + 1 < revisions?.length) ? revisions[idx + 1] : revision;
+
+              const isOldestRevision = revision === oldestRevision;
+              const latestRevision = revisions[0];
+
+              // set 'true' if undefined for backward compatibility
+              const hasDiff = revision.hasDiffToPrev !== false;
+              return renderRow(revision, previousRevision, latestRevision, isOldestRevision, hasDiff);
+            })
+          }
+        </tbody>
+      </table>
 
       { sourceRevision && targetRevision && (
         <RevisionComparer
           sourceRevision={sourceRevision}
           targetRevision={targetRevision}
+          currentPageId={currentPageId}
+          currentPagePath={currentPagePath}
           onClose={onClose}
         />)
       }

+ 4 - 8
packages/app/src/components/PageHistory/Revision.tsx

@@ -1,14 +1,11 @@
 import React from 'react';
 
-import { IRevisionHasId, pathUtils } from '@growi/core';
+import { IRevisionHasId, Nullable, pathUtils } from '@growi/core';
 import { UserPicture } from '@growi/ui';
 import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
 import urljoin from 'url-join';
 
-import { useCurrentPageId } from '~/stores/context';
-import { useCurrentPagePath } from '~/stores/page';
-
 import UserDate from '../User/UserDate';
 import { Username } from '../User/Username';
 
@@ -18,6 +15,8 @@ type RevisionProps = {
   revision: IRevisionHasId,
   isLatestRevision: boolean,
   hasDiff: boolean,
+  currentPageId: Nullable<string> | undefined,
+  currentPagePath: Nullable<string> | undefined,
   onClose: () => void,
 }
 
@@ -25,12 +24,9 @@ export const Revision = (props: RevisionProps): JSX.Element => {
   const { t } = useTranslation();
 
   const {
-    revision, isLatestRevision, hasDiff, onClose,
+    revision, isLatestRevision, hasDiff, onClose, currentPageId, currentPagePath,
   } = props;
 
-  const { data: currentPageId } = useCurrentPageId();
-  const { data: currentPagePath } = useCurrentPagePath();
-
   const { returnPathForURL } = pathUtils;
 
   const renderSimplifiedNodiff = (revision: IRevisionHasId) => {

+ 5 - 7
packages/app/src/components/RevisionComparer/RevisionComparer.tsx

@@ -1,15 +1,12 @@
 import React, { useState } from 'react';
 
-import { IRevisionHasPageId, pagePathUtils } from '@growi/core';
+import { IRevisionHasPageId, Nullable, pagePathUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
 import {
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
 
-import { useCurrentPageId } from '~/stores/context';
-import { useCurrentPagePath } from '~/stores/page';
-
 import { RevisionDiff } from '../PageHistory/RevisionDiff';
 
 import styles from './RevisionComparer.module.scss';
@@ -26,6 +23,8 @@ const DropdownItemContents = ({ title, contents }) => (
 type RevisionComparerProps = {
   sourceRevision: IRevisionHasPageId
   targetRevision: IRevisionHasPageId
+  currentPageId: Nullable<string> | undefined
+  currentPagePath: Nullable<string> | undefined
   onClose: () => void
 }
 
@@ -33,12 +32,11 @@ export const RevisionComparer = (props: RevisionComparerProps): JSX.Element => {
   const { t } = useTranslation(['translation', 'commons']);
 
   const {
-    sourceRevision, targetRevision, onClose,
+    sourceRevision, targetRevision, onClose, currentPageId, currentPagePath,
   } = props;
 
   const [dropdownOpen, setDropdownOpen] = useState(false);
-  const { data: currentPageId } = useCurrentPageId();
-  const { data: currentPagePath } = useCurrentPagePath();
+
 
   const toggleDropdown = () => {
     setDropdownOpen(!dropdownOpen);

+ 10 - 5
packages/app/src/server/routes/apiv3/revisions.js

@@ -72,7 +72,7 @@ module.exports = (crowi) => {
   const validator = {
     retrieveRevisions: [
       query('pageId').isMongoId().withMessage('pageId is required'),
-      query('page').isInt({ min: 0 }).withMessage('page must be int'),
+      query('offset').if(value => value != null).isInt({ min: 0 }).withMessage('offset must be int'),
       query('limit').if(value => value != null).isInt({ max: 100 }).withMessage('You should set less than 100 or not to set limit.'),
 
     ],
@@ -114,8 +114,7 @@ module.exports = (crowi) => {
     const pageId = req.query.pageId;
     const limit = req.query.limit || await crowi.configManager.getConfig('crowi', 'customize:showPageLimitationS') || 10;
     const { isSharedPage } = req;
-
-    const selectedPage = parseInt(req.query.page) || 1;
+    const offset = req.query.offset || 0;
 
     // check whether accessible
     if (!isSharedPage && !(await Page.isAccessiblePageByViewer(pageId, req.user))) {
@@ -128,7 +127,7 @@ module.exports = (crowi) => {
       const paginateResult = await Revision.paginate(
         { pageId: page._id },
         {
-          page: selectedPage,
+          offset,
           limit,
           sort: { createdAt: -1 },
           populate: 'author',
@@ -141,7 +140,13 @@ module.exports = (crowi) => {
         }
       });
 
-      return res.apiv3(paginateResult);
+      const result = {
+        revisions: paginateResult.docs,
+        totalCount: paginateResult.totalDocs,
+        offset: paginateResult.offset,
+      };
+
+      return res.apiv3(result);
     }
     catch (err) {
       const msg = 'Error occurred in getting revisions by poge id';

+ 22 - 11
packages/app/src/stores/page.tsx

@@ -164,20 +164,31 @@ export const useSWRxPageRevisions = (
   );
 };
 
+
+/*
+ * SWR Infinite for page revision list
+ */
+type SWRInfinitePageRevisionsResponse = {
+  revisions: IRevisionHasPageId[],
+  totalCount: number,
+  offset: number,
+}
+
 export const useSWRxInfinitePageRevisions = (
     pageId: string | null | undefined,
-): SWRInfiniteResponse<IRevisionHasPageId[], Error> => {
-  const LIMIT = 10;
-  const getKey = (page: number) => {
-    return `/revisions/list?pageId=${pageId}&page=${page + 1}&limit=${LIMIT}`;
-  };
+    limit: number,
+): SWRInfiniteResponse<SWRInfinitePageRevisionsResponse, Error> => {
   return useSWRInfinite(
-    getKey,
-    (endpoint: string) => apiv3Get(endpoint).then((response) => {
-      return response.data.docs.map((data) => {
-        return data;
-      });
-    }),
+    (pageIndex, previousRevisionData) => {
+      if (previousRevisionData != null && previousRevisionData.revisions.length === 0) return null;
+
+      if (pageIndex === 0 || previousRevisionData == null) {
+        return ['/revisions/list', pageId, undefined, limit];
+      }
+      const offset = previousRevisionData.offset + limit;
+      return ['/revisions/list', pageId, offset, limit];
+    },
+    ([endpoint, pageId, offset, limit]) => apiv3Get<SWRInfinitePageRevisionsResponse>(endpoint, { pageId, offset, limit }).then(response => response.data),
     {
       revalidateFirstPage: false,
       revalidateAll: false,