Browse Source

Merge pull request #5647 from weseek/feat/gw7756-infinite-scroll-recent-changes-sidebar

feat: Infinite scroll for Recent Changes in Sidebar
Yuki Takei 4 năm trước cách đây
mục cha
commit
a66cedf00a

+ 73 - 0
packages/app/src/components/Sidebar/InfiniteScroll.tsx

@@ -0,0 +1,73 @@
+import React, {
+  Ref, useEffect, useState,
+} from 'react';
+import type { SWRInfiniteResponse } from 'swr/infinite';
+
+type Props<T> = {
+  swrInifiniteResponse : SWRInfiniteResponse<T>
+  children: React.ReactChild | ((item: T) => React.ReactNode),
+  loadingIndicator?: React.ReactNode
+  endingIndicator?: React.ReactNode
+  isReachingEnd?: boolean,
+  offset?: number
+}
+
+const useIntersection = <E extends HTMLElement>(): [boolean, Ref<E>] => {
+  const [intersecting, setIntersecting] = useState<boolean>(false);
+  const [element, setElement] = useState<HTMLElement>();
+  useEffect(() => {
+    if (element != null) {
+      const observer = new IntersectionObserver((entries) => {
+        setIntersecting(entries[0]?.isIntersecting);
+      });
+      observer.observe(element);
+      return () => observer.unobserve(element);
+    }
+    return;
+  }, [element]);
+  return [intersecting, el => el && setElement(el)];
+};
+
+const LoadingIndicator = (): React.ReactElement => {
+  return (
+    <div className="text-muted text-center">
+      <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
+    </div>
+  );
+};
+
+const InfiniteScroll = <E, >(props: Props<E>): React.ReactElement<Props<E>> => {
+  const {
+    swrInifiniteResponse: {
+      setSize, data, isValidating,
+    },
+    children,
+    loadingIndicator,
+    endingIndicator,
+    isReachingEnd,
+    offset = 0,
+  } = props;
+
+  const [intersecting, ref] = useIntersection<HTMLDivElement>();
+
+  useEffect(() => {
+    if (intersecting && !isValidating && !isReachingEnd) {
+      setSize(size => size + 1);
+    }
+  }, [setSize, intersecting]);
+
+  return (
+    <>
+      {typeof children === 'function' ? data?.map(item => children(item)) : children}
+      <div style={{ position: 'relative' }}>
+        <div ref={ref} style={{ position: 'absolute', top: offset }}></div>
+        {isReachingEnd
+          ? endingIndicator
+          : loadingIndicator || <LoadingIndicator />
+        }
+      </div>
+    </>
+  );
+};
+
+export default InfiniteScroll;

+ 18 - 12
packages/app/src/components/Sidebar/RecentChanges.tsx

@@ -10,11 +10,11 @@ import { UserPicture, FootstampIcon } from '@growi/ui';
 import { DevidedPagePath } from '@growi/core';
 
 import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
-import { useSWRxRecentlyUpdated } from '~/stores/page';
+import { useSWRInifinitexRecentlyUpdated } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 
 import LinkedPagePath from '~/models/linked-page-path';
-
+import InfiniteScroll from './InfiniteScroll';
 
 import FormattedDistanceDate from '../FormattedDistanceDate';
 
@@ -120,14 +120,13 @@ SmallPageItem.propTypes = {
   page: PropTypes.any,
 };
 
-
 const RecentChanges = (): JSX.Element => {
-
+  const PER_PAGE = 20;
   const { t } = useTranslation();
-  const { data: pages, mutate } = useSWRxRecentlyUpdated();
-
+  const swr = useSWRInifinitexRecentlyUpdated();
   const [isRecentChangesSidebarSmall, setIsRecentChangesSidebarSmall] = useState(false);
-
+  const isEmpty = swr.data?.[0].length === 0;
+  const isReachingEnd = isEmpty || (swr.data && swr.data[swr.data.length - 1]?.length < PER_PAGE);
   const retrieveSizePreferenceFromLocalStorage = useCallback(() => {
     if (window.localStorage.isRecentChangesSidebarSmall === 'true') {
       setIsRecentChangesSidebarSmall(true);
@@ -148,7 +147,7 @@ const RecentChanges = (): JSX.Element => {
     <>
       <div className="grw-sidebar-content-header p-3 d-flex">
         <h3 className="mb-0  text-nowrap">{t('Recent Changes')}</h3>
-        <button type="button" className="btn btn-sm ml-auto grw-btn-reload" onClick={() => mutate()}>
+        <button type="button" className="btn btn-sm ml-auto grw-btn-reload" onClick={() => swr.mutate()}>
           <i className="icon icon-reload"></i>
         </button>
         <div className="d-flex align-items-center">
@@ -167,14 +166,21 @@ const RecentChanges = (): JSX.Element => {
       </div>
       <div className="grw-recent-changes p-3">
         <ul className="list-group list-group-flush">
-          {(pages || []).map(page => (isRecentChangesSidebarSmall
-            ? <SmallPageItem key={page._id} page={page} />
-            : <LargePageItem key={page._id} page={page} />))}
+          <InfiniteScroll
+            swrInifiniteResponse={swr}
+            isReachingEnd={isReachingEnd}
+          >
+            {pages => pages.map(page => (
+              isRecentChangesSidebarSmall
+                ? <SmallPageItem key={page._id} page={page} />
+                : <LargePageItem key={page._id} page={page} />
+            ))
+            }
+          </InfiniteScroll>
         </ul>
       </div>
     </>
   );
 
 };
-
 export default RecentChanges;

+ 0 - 2
packages/app/src/server/models/obsolete-page.js

@@ -485,7 +485,6 @@ export const getPageSchema = (crowi) => {
     validateCrowi();
 
     const User = crowi.model('User');
-
     const opt = Object.assign({ sort: 'updatedAt', desc: -1 }, option);
     const sortOpt = {};
     sortOpt[opt.sort] = opt.desc;
@@ -505,7 +504,6 @@ export const getPageSchema = (crowi) => {
     builder.addConditionToPagenate(opt.offset, opt.limit, sortOpt);
     builder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
     const pages = await builder.query.lean().clone().exec('find');
-
     const result = {
       pages, totalCount, offset: opt.offset, limit: opt.limit,
     };

+ 41 - 1
packages/app/src/server/models/page.ts

@@ -44,6 +44,13 @@ type TargetAndAncestorsResult = {
   rootPage: PageDocument
 }
 
+type PaginatedPages = {
+  pages: PageDocument[],
+  totalCount: number,
+  limit: number,
+  offset: number
+}
+
 export type CreateMethod = (path: string, body: string, user, options) => Promise<PageDocument & { _id: any }>
 export interface PageModel extends Model<PageDocument> {
   [x: string]: any; // for obsolete methods
@@ -54,7 +61,7 @@ export interface PageModel extends Model<PageDocument> {
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>
   findAncestorsChildrenByPathAndViewer(path: string, user, userGroups?): Promise<Record<string, PageDocument[]>>
-
+  findRecentUpdatedPages(path: string, user, option, includeEmpty?: boolean): Promise<PaginatedPages>
   generateGrantCondition(
     user, userGroups, showAnyoneKnowsLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
   ): { $or: any[] }
@@ -700,6 +707,39 @@ schema.statics.findByPathAndViewer = async function(
   return queryBuilder.query.exec();
 };
 
+schema.statics.findRecentUpdatedPages = async function(
+    path: string, user, options, includeEmpty = false,
+): Promise<PaginatedPages> {
+
+  const sortOpt = {};
+  sortOpt[options.sort] = options.desc;
+
+  const Page = this;
+  const User = mongoose.model('User') as any;
+
+  if (path == null) {
+    throw new Error('path is required.');
+  }
+
+  const baseQuery = this.find({});
+  const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
+  if (!options.includeTrashed) {
+    queryBuilder.addConditionToExcludeTrashed();
+  }
+
+  queryBuilder.addConditionToListWithDescendants(path, options);
+  queryBuilder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
+  await addViewerCondition(queryBuilder, user);
+  const pages = await Page.paginate(queryBuilder.query.clone(), {
+    lean: true, sort: sortOpt, offset: options.offset, limit: options.limit,
+  });
+  const results = {
+    pages: pages.docs, totalCount: pages.totalDocs, offset: options.offset, limit: options.limit,
+  };
+
+  return results;
+};
+
 
 /*
  * Find all ancestor pages by path. When duplicate pages found, it uses the oldest page as a result

+ 6 - 9
packages/app/src/server/routes/apiv3/pages.js

@@ -1,16 +1,14 @@
-import loggerFactory from '~/utils/logger';
-
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
+import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
-const express = require('express');
 const { pathUtils, pagePathUtils } = require('@growi/core');
-const mongoose = require('mongoose');
-
+const express = require('express');
 const { body } = require('express-validator');
 const { query } = require('express-validator');
+const mongoose = require('mongoose');
 
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
@@ -366,18 +364,17 @@ module.exports = (crowi) => {
   router.get('/recent', accessTokenParser, loginRequired, async(req, res) => {
     const limit = 20;
     const offset = parseInt(req.query.offset) || 0;
-
+    const skip = offset > 0 ? (offset - 1) * limit : offset;
     const queryOptions = {
-      offset,
+      offset: skip,
       limit,
       includeTrashed: false,
       isRegExpEscapedFromPath: true,
       sort: 'updatedAt',
       desc: -1,
     };
-
     try {
-      const result = await Page.findListWithDescendants('/', req.user, queryOptions);
+      const result = await Page.findRecentUpdatedPages('/', req.user, queryOptions);
       if (result.pages.length > limit) {
         result.pages.pop();
       }

+ 14 - 1
packages/app/src/stores/page.tsx

@@ -1,4 +1,5 @@
 import useSWR, { SWRResponse } from 'swr';
+import useSWRInfinite, { SWRInfiniteResponse } from 'swr/infinite';
 import useSWRImmutable from 'swr/immutable';
 
 import { apiv3Get } from '~/client/util/apiv3-client';
@@ -33,7 +34,19 @@ export const useSWRxRecentlyUpdated = (): SWRResponse<(IPageHasId)[], Error> =>
     endpoint => apiv3Get<{ pages:(IPageHasId)[] }>(endpoint).then(response => response.data?.pages),
   );
 };
-
+export const useSWRInifinitexRecentlyUpdated = () : SWRInfiniteResponse<(IPageHasId)[], Error> => {
+  const getKey = (page: number) => {
+    return `/pages/recent?offset=${page + 1}`;
+  };
+  return useSWRInfinite(
+    getKey,
+    (endpoint: string) => apiv3Get<{ pages:(IPageHasId)[] }>(endpoint).then(response => response.data?.pages),
+    {
+      revalidateFirstPage: false,
+      revalidateAll: false,
+    },
+  );
+};
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 export const useSWRxPageList = (path: string | null, pageNumber?: number, termNumber?: number): SWRResponse<IPagingResult<IPageHasId>, Error> => {