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

Merge pull request #7774 from weseek/feat/lsx-load-more

feat(lsx):  Load more
Yuki Takei 2 лет назад
Родитель
Сommit
672b4dbd1e

+ 22 - 11
.vscode/launch.json

@@ -2,17 +2,7 @@
     "version": "0.2.0",
     "configurations": [
       {
-        "type": "pwa-node",
-        "request": "attach",
-        "name": "Debug: Attach Debugger to Server",
-        "port": 9229,
-        "cwd": "${workspaceFolder}/apps/app",
-        "sourceMapPathOverrides": {
-          "webpack://@growi/app/*": "${workspaceFolder}/apps/app/*"
-        }
-      },
-      {
-        "type": "pwa-node",
+        "type": "node",
         "request": "launch",
         "name": "Debug: Current File",
         "skipFiles": [
@@ -26,6 +16,27 @@
           "${file}"
         ]
       },
+      {
+        "type": "node",
+        "request": "launch",
+        "name": "Debug: Current File with Vitest",
+        "autoAttachChildProcesses": true,
+        "skipFiles": ["<node_internals>/**", "**/node_modules/**"],
+        "program": "${workspaceRoot}/node_modules/vitest/vitest.mjs",
+        "args": ["run", "${relativeFile}"],
+        "smartStep": true,
+        "console": "integratedTerminal"
+      },
+      {
+        "type": "pwa-node",
+        "request": "attach",
+        "name": "Debug: Attach Debugger to Server",
+        "port": 9229,
+        "cwd": "${workspaceFolder}/apps/app",
+        "sourceMapPathOverrides": {
+          "webpack://@growi/app/*": "${workspaceFolder}/apps/app/*"
+        }
+      },
       {
         "type": "pwa-node",
         "request": "launch",

+ 0 - 75
apps/app/src/components/Sidebar/InfiniteScroll.tsx

@@ -1,75 +0,0 @@
-import React, {
-  Ref, useEffect, useState,
-} from 'react';
-
-import type { SWRInfiniteResponse } from 'swr/infinite';
-
-type Props<T> = {
-  swrInifiniteResponse : SWRInfiniteResponse<T>
-  children: 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, isValidating,
-    },
-    children,
-    loadingIndicator,
-    endingIndicator,
-    isReachingEnd,
-    offset = 0,
-  } = props;
-
-  const [intersecting, ref] = useIntersection<HTMLDivElement>();
-
-  useEffect(() => {
-    if (intersecting && !isValidating && !isReachingEnd) {
-      setSize(size => size + 1);
-    }
-  }, [setSize, intersecting, isValidating, isReachingEnd]);
-
-  return (
-    <>
-      { children }
-
-      <div style={{ position: 'relative' }}>
-        <div ref={ref} style={{ position: 'absolute', top: offset }}></div>
-        {isReachingEnd
-          ? endingIndicator
-          : loadingIndicator || <LoadingIndicator />
-        }
-      </div>
-    </>
-  );
-};
-
-export default InfiniteScroll;

+ 30 - 0
packages/remark-lsx/src/components/Lsx.module.scss

@@ -6,6 +6,36 @@
       animation: lsx-fadeIn 1s ease 0s infinite alternate;
     }
   }
+
+  .lsx-load-more-row {
+    opacity: .5;
+
+    .left-items-label {
+      display: none;
+    }
+    .btn-load-more {
+      border-right-style: hidden;
+      border-bottom-style: hidden;
+      border-left-style: hidden;
+      border-radius: 0;
+    }
+  }
+  .lsx-load-more-row:hover {
+    opacity: 1;
+
+    .left-items-label {
+      display: inline;
+    }
+    .btn-load-more {
+      border-style: solid;
+    }
+  }
+
+  .lsx-load-more-container {
+    max-width: 250px;
+    border-top: 1px black;
+  }
+
 }
 
 @keyframes lsx-fadeIn {

+ 46 - 6
packages/remark-lsx/src/components/Lsx.tsx

@@ -1,7 +1,8 @@
 import React, { useCallback, useMemo } from 'react';
 
 
-import { useSWRxNodeTree } from '../stores/lsx';
+import { useSWRxLsx } from '../stores/lsx';
+import { generatePageNodeTree } from '../utils/page-node';
 
 import { LsxListView } from './LsxPageList/LsxListView';
 import { LsxContext } from './lsx-context';
@@ -37,9 +38,10 @@ const LsxSubstance = React.memo(({
     return new LsxContext(prefix, options);
   }, [depth, filter, num, prefix, reverse, sort, except]);
 
-  const { data, error, isLoading: _isLoading } = useSWRxNodeTree(lsxContext, isImmutable);
+  const {
+    data, error, isLoading, setSize,
+  } = useSWRxLsx(lsxContext.pagePath, lsxContext.options, isImmutable);
 
-  const isLoading = _isLoading || data === undefined;
   const hasError = error != null;
   const errorMessage = error?.message;
 
@@ -77,18 +79,56 @@ const LsxSubstance = React.memo(({
   }, [hasError, isLoading, lsxContext]);
 
   const contents = useMemo(() => {
-    if (isLoading) {
+    if (data == null) {
       return <></>;
     }
 
-    return <LsxListView nodeTree={data.nodeTree} lsxContext={lsxContext} basisViewersCount={data.toppageViewersCount} />;
-  }, [data?.nodeTree, data?.toppageViewersCount, isLoading, lsxContext]);
+    const depthRange = lsxContext.getOptDepth();
+
+    const nodeTree = generatePageNodeTree(prefix, data.flatMap(d => d.pages), depthRange);
+    const basisViewersCount = data.at(-1)?.toppageViewersCount;
+
+    return <LsxListView nodeTree={nodeTree} lsxContext={lsxContext} basisViewersCount={basisViewersCount} />;
+  }, [data, lsxContext, prefix]);
+
+
+  const LoadMore = useCallback(() => {
+    const lastResult = data?.at(-1);
+
+    if (lastResult == null) {
+      return <></>;
+    }
+
+    const { cursor, total } = lastResult;
+    const leftItemsNum = total - cursor;
+
+    if (leftItemsNum === 0) {
+      return <></>;
+    }
+
+    return (
+      <div className="row justify-content-center lsx-load-more-row">
+        <div className="col-12 col-sm-8 d-flex flex-column align-items-center lsx-load-more-container">
+          <button
+            type="button"
+            className="btn btn btn-block btn-outline-secondary btn-load-more"
+            onClick={() => setSize(size => size + 1)}
+          >
+            Load more<br />
+            <span className="text-muted small left-items-label">({leftItemsNum} pages left)</span>
+          </button>
+        </div>
+      </div>
+    );
+  }, [data, setSize]);
+
 
   return (
     <div className={`lsx ${styles.lsx}`}>
       <Error />
       <Loading />
       {contents}
+      <LoadMore />
     </div>
   );
 });

+ 23 - 0
packages/remark-lsx/src/interfaces/api.ts

@@ -0,0 +1,23 @@
+import { IPageHasId } from '@growi/core';
+
+export type LsxApiOptions = {
+  depth?: string,
+  filter?: string,
+  except?: string,
+  sort?: string,
+  reverse?: string,
+}
+
+export type LsxApiParams = {
+  pagePath: string,
+  offset?: number,
+  limit?: number,
+  options?: LsxApiOptions,
+}
+
+export type LsxApiResponseData = {
+  pages: IPageHasId[],
+  cursor: number,
+  total: number,
+  toppageViewersCount: number,
+}

+ 16 - 19
packages/remark-lsx/src/server/routes/list-pages/add-depth-condition.ts

@@ -1,41 +1,38 @@
-import { OptionParser } from '@growi/core/dist/plugin';
-import { pagePathUtils } from '@growi/core/dist/utils';
+import type { ParseRangeResult } from '@growi/core';
 import createError from 'http-errors';
 
-import type { PageQuery } from './generate-base-query';
-
-const { isTopPage } = pagePathUtils;
+import { getDepthOfPath } from '../../../utils/depth-utils';
 
-export const addDepthCondition = (query: PageQuery, pagePath: string, optionsDepth: string): PageQuery => {
+import type { PageQuery } from './generate-base-query';
 
-  const range = OptionParser.parseRange(optionsDepth);
+export const addDepthCondition = (query: PageQuery, pagePath: string, depthRange: ParseRangeResult | null): PageQuery => {
 
-  if (range == null) {
+  if (depthRange == null) {
     return query;
   }
 
-  const start = range.start;
-  const end = range.end;
+  const { start, end } = depthRange;
 
   // check start
   if (start < 1) {
-    throw createError(400, `specified depth is [${start}:${end}] : the start must be larger or equal than 1`);
+    throw createError(400, `The specified option 'depth' is { start: ${start}, end: ${end} } : the start must be larger or equal than 1`);
+  }
+  // check end
+  if (start > end && end > 0) {
+    throw createError(400, `The specified option 'depth' is { start: ${start}, end: ${end} } : the end must be larger or equal than the start`);
   }
 
-  // count slash
-  const slashNum = isTopPage(pagePath)
-    ? 1
-    : pagePath.split('/').length;
-  const depthStart = slashNum + start - 1;
-  const depthEnd = slashNum + end - 1;
+  const depthOfPath = getDepthOfPath(pagePath);
+  const slashNumStart = depthOfPath + depthRange.start;
+  const slashNumEnd = depthOfPath + depthRange.end;
 
   if (end < 0) {
     return query.and([
-      { path: new RegExp(`^(\\/[^\\/]*){${depthStart},}$`) },
+      { path: new RegExp(`^(\\/[^\\/]*){${slashNumStart},}$`) },
     ]);
   }
 
   return query.and([
-    { path: new RegExp(`^(\\/[^\\/]*){${depthStart},${depthEnd}}$`) },
+    { path: new RegExp(`^(\\/[^\\/]*){${slashNumStart},${slashNumEnd}}$`) },
   ]);
 };

+ 41 - 56
packages/remark-lsx/src/server/routes/list-pages/add-num-condition.spec.ts

@@ -1,92 +1,77 @@
-import { OptionParser } from '@growi/core/dist/plugin';
 import createError from 'http-errors';
 import { mock } from 'vitest-mock-extended';
 
 import { addNumCondition } from './add-num-condition';
 import type { PageQuery } from './generate-base-query';
 
-describe('addNumCondition()', () => {
+describe('addNumCondition() throws 400 http-errors instance ', () => {
 
-  const queryMock = mock<PageQuery>();
+  it("when the param 'offset' is a negative value", () => {
 
-  it('set limit with the specified number', () => {
     // setup
-    const parseRangeSpy = vi.spyOn(OptionParser, 'parseRange');
-
-    const queryLimitResultMock = mock<PageQuery>();
-    queryMock.limit.calledWith(99).mockImplementation(() => queryLimitResultMock);
-
-    // when
-    const result = addNumCondition(queryMock, 99);
-
-    // then
-    expect(queryMock.limit).toHaveBeenCalledWith(99);
-    expect(result).toEqual(queryLimitResultMock);
-    expect(parseRangeSpy).not.toHaveBeenCalled();
-  });
-
-  it('returns the specified qeury as-is when the option value is invalid', () => {
-    // setup
-    const parseRangeSpy = vi.spyOn(OptionParser, 'parseRange');
-
-    // when
-    const result = addNumCondition(queryMock, 'invalid string');
-
-    // then
-    expect(queryMock.limit).not.toHaveBeenCalled();
-    expect(parseRangeSpy).toHaveBeenCalledWith('invalid string');
-    expect(result).toEqual(queryMock);
-  });
-
-  it('throws 400 http-errors instance when the start value is smaller than 1', () => {
-    // setup
-    const parseRangeSpy = vi.spyOn(OptionParser, 'parseRange');
+    const queryMock = mock<PageQuery>();
 
     // when
-    const caller = () => addNumCondition(queryMock, '-1:10');
+    const caller = () => addNumCondition(queryMock, -1, 10);
 
     // then
-    expect(caller).toThrowError(createError(400, 'specified num is [-1:10] : the start must be larger or equal than 1'));
+    expect(caller).toThrowError(createError(400, "The param 'offset' must be larger or equal than 0"));
+    expect(queryMock.skip).not.toHaveBeenCalledWith();
     expect(queryMock.limit).not.toHaveBeenCalledWith();
-    expect(parseRangeSpy).toHaveBeenCalledWith('-1:10');
   });
-
 });
 
 
-describe('addNumCondition() set skip and limit with the range string', () => {
+describe('addNumCondition() set skip and limit with', () => {
 
   it.concurrent.each`
-    optionsNum    | expectedSkip    | expectedLimit   | isExpectedToSetLimit
-    ${'1:10'}     | ${0}            | ${10}           | ${true}
-    ${'3:'}       | ${2}            | ${-1}           | ${false}
-  `("'$optionsNum", ({
-    optionsNum, expectedSkip, expectedLimit, isExpectedToSetLimit,
+    offset        | limit           | expectedSkip   | expectedLimit
+    ${1}          | ${-1}           | ${1}           | ${null}
+    ${0}          | ${0}            | ${null}        | ${0}
+    ${0}          | ${10}           | ${null}        | ${10}
+    ${NaN}        | ${NaN}          | ${null}        | ${null}
+    ${undefined}  | ${undefined}    | ${null}        | ${50}
+  `("{ offset: $offset, limit: $limit }'", ({
+    offset, limit, expectedSkip, expectedLimit,
   }) => {
     // setup
     const queryMock = mock<PageQuery>();
 
+    // result for q.skip()
     const querySkipResultMock = mock<PageQuery>();
     queryMock.skip.calledWith(expectedSkip).mockImplementation(() => querySkipResultMock);
-
+    // result for q.limit()
     const queryLimitResultMock = mock<PageQuery>();
-    querySkipResultMock.limit.calledWith(expectedLimit).mockImplementation(() => queryLimitResultMock);
-
-    const parseRangeSpy = vi.spyOn(OptionParser, 'parseRange');
+    queryMock.limit.calledWith(expectedLimit).mockImplementation(() => queryLimitResultMock);
+    // result for q.skil().limit()
+    const querySkipAndLimitResultMock = mock<PageQuery>();
+    querySkipResultMock.limit.calledWith(expectedLimit).mockImplementation(() => querySkipAndLimitResultMock);
 
     // when
-    const result = addNumCondition(queryMock, optionsNum);
+    const result = addNumCondition(queryMock, offset, limit);
 
     // then
-    expect(parseRangeSpy).toHaveBeenCalledWith(optionsNum);
-    expect(queryMock.skip).toHaveBeenCalledWith(expectedSkip);
-    if (isExpectedToSetLimit) {
-      expect(querySkipResultMock.limit).toHaveBeenCalledWith(expectedLimit);
-      expect(result).toEqual(queryLimitResultMock);
+    if (expectedSkip != null) {
+      expect(queryMock.skip).toHaveBeenCalledWith(expectedSkip);
+      if (expectedLimit != null) {
+        expect(querySkipResultMock.limit).toHaveBeenCalledWith(expectedLimit);
+        expect(result).toEqual(querySkipAndLimitResultMock); // q.skip().limit()
+      }
+      else {
+        expect(querySkipResultMock.limit).not.toHaveBeenCalled();
+        expect(result).toEqual(querySkipResultMock); // q.skil()
+      }
     }
     else {
-      expect(querySkipResultMock.limit).not.toHaveBeenCalled();
-      expect(result).toEqual(querySkipResultMock);
+      expect(queryMock.skip).not.toHaveBeenCalled();
+      if (expectedLimit != null) {
+        expect(queryMock.limit).toHaveBeenCalledWith(expectedLimit);
+        expect(result).toEqual(queryLimitResultMock); // q.limit()
+      }
+      else {
+        expect(queryMock.limit).not.toHaveBeenCalled();
+        expect(result).toEqual(queryMock); // as-is
+      }
     }
   });
 

+ 15 - 22
packages/remark-lsx/src/server/routes/list-pages/add-num-condition.ts

@@ -1,38 +1,31 @@
-import { OptionParser } from '@growi/core/dist/plugin';
 import createError from 'http-errors';
 
 import type { PageQuery } from './generate-base-query';
 
 
+const DEFAULT_PAGES_NUM = 50;
+
 /**
  * add num condition that limit fetched pages
  */
-export const addNumCondition = (query: PageQuery, optionsNum: string | number): PageQuery => {
+export const addNumCondition = (query: PageQuery, offset = 0, limit = DEFAULT_PAGES_NUM): PageQuery => {
 
-  if (typeof optionsNum === 'number') {
-    return query.limit(optionsNum);
+  // check offset
+  if (offset < 0) {
+    throw createError(400, "The param 'offset' must be larger or equal than 0");
   }
-
-  const range = OptionParser.parseRange(optionsNum);
-
-  if (range == null) {
-    return query;
+  // check offset
+  if (offset < 0) {
+    throw createError(400, "The param 'offset' must be larger or equal than 0");
   }
 
-  const start = range.start;
-  const end = range.end;
-
-  // check start
-  if (start < 1) {
-    throw createError(400, `specified num is [${start}:${end}] : the start must be larger or equal than 1`);
+  let q = query;
+  if (offset > 0) {
+    q = q.skip(offset);
   }
-
-  const skip = start - 1;
-  const limit = end - skip;
-
-  if (limit < 0) {
-    return query.skip(skip);
+  if (limit >= 0) {
+    q = q.limit(limit);
   }
 
-  return query.skip(skip).limit(limit);
+  return q;
 };

+ 3 - 3
packages/remark-lsx/src/server/routes/list-pages/generate-base-query.ts

@@ -1,8 +1,8 @@
-import { IPage, IUser } from '@growi/core';
+import type { IPageHasId, IUser } from '@growi/core';
 import { model } from 'mongoose';
 import type { Document, Query } from 'mongoose';
 
-export type PageQuery = Query<IPage[], Document>;
+export type PageQuery = Query<IPageHasId[], Document>;
 
 export type PageQueryBuilder = {
   query: PageQuery,
@@ -11,7 +11,7 @@ export type PageQueryBuilder = {
 };
 
 export const generateBaseQuery = async(pagePath: string, user: IUser): Promise<PageQueryBuilder> => {
-  const Page = model<IPage>('Page');
+  const Page = model<IPageHasId>('Page');
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   const PageAny = Page as any;
 

+ 35 - 16
packages/remark-lsx/src/server/routes/list-pages/index.spec.ts

@@ -1,9 +1,11 @@
-import { IPage, IUser } from '@growi/core';
+import { IPageHasId, IUser } from '@growi/core';
 import type { Request, Response } from 'express';
 import createError from 'http-errors';
 import { mock } from 'vitest-mock-extended';
 
-import type { PageQuery } from './generate-base-query';
+import type { LsxApiResponseData } from '../../../interfaces/api';
+
+import type { PageQuery, PageQueryBuilder } from './generate-base-query';
 
 import { listPages } from '.';
 
@@ -44,16 +46,30 @@ describe('listPages', () => {
 
   describe('with num option', () => {
 
-    mocks.generateBaseQueryMock.mockImplementation(() => vi.fn());
+    const reqMock = mock<Request & { user: IUser }>();
+    reqMock.query = { pagePath: '/Sandbox' };
+
+    const builderMock = mock<PageQueryBuilder>();
+
+    mocks.generateBaseQueryMock.mockResolvedValue(builderMock);
     mocks.getToppageViewersCountMock.mockImplementation(() => 99);
 
+    const queryMock = mock<PageQuery>();
+    builderMock.query = queryMock;
+
     it('returns 200 HTTP response', async() => {
-      // setup
-      const reqMock = mock<Request & { user: IUser }>();
-      reqMock.query = { pagePath: '/Sandbox' };
+      // setup query.clone().count()
+      const queryClonedMock = mock<PageQuery>();
+      queryMock.clone.mockImplementation(() => queryClonedMock);
+      queryClonedMock.count.mockResolvedValue(9);
+
+      // setup addNumCondition
+      mocks.addNumConditionMock.mockImplementation(() => queryMock);
+      // setup addSortCondition
+      mocks.addSortConditionMock.mockImplementation(() => queryMock);
 
-      const pageMock = mock<IPage>();
-      const queryMock = mock<PageQuery>();
+      // setup query.exec()
+      const pageMock = mock<IPageHasId>();
       queryMock.exec.mockImplementation(async() => [pageMock]);
       mocks.addSortConditionMock.mockImplementation(() => queryMock);
 
@@ -70,10 +86,13 @@ describe('listPages', () => {
       expect(mocks.addNumConditionMock).toHaveBeenCalledOnce();
       expect(mocks.addSortConditionMock).toHaveBeenCalledOnce();
       expect(resMock.status).toHaveBeenCalledOnce();
-      expect(resStatusMock.send).toHaveBeenCalledWith({
+      const expectedResponseData: LsxApiResponseData = {
         pages: [pageMock],
+        cursor: 1,
+        total: 9,
         toppageViewersCount: 99,
-      });
+      };
+      expect(resStatusMock.send).toHaveBeenCalledWith(expectedResponseData);
     });
 
     it('returns 500 HTTP response when an unexpected error occured', async() => {
@@ -82,9 +101,9 @@ describe('listPages', () => {
       reqMock.query = { pagePath: '/Sandbox' };
 
       // an Error instance will be thrown by addNumConditionMock
-      const expectedError = new Error('error for test');
+      const error = new Error('error for test');
       mocks.addNumConditionMock.mockImplementation(() => {
-        throw expectedError;
+        throw error;
       });
 
       const resMock = mock<Response>();
@@ -100,7 +119,7 @@ describe('listPages', () => {
       expect(mocks.addNumConditionMock).toHaveBeenCalledOnce(); // throw an error
       expect(mocks.addSortConditionMock).not.toHaveBeenCalledOnce(); // does not called
       expect(resMock.status).toHaveBeenCalledOnce();
-      expect(resStatusMock.send).toHaveBeenCalledWith(expectedError);
+      expect(resStatusMock.send).toHaveBeenCalledWith('error for test');
     });
 
     it('returns 400 HTTP response when the value is invalid', async() => {
@@ -109,9 +128,9 @@ describe('listPages', () => {
       reqMock.query = { pagePath: '/Sandbox' };
 
       // an http-errors instance will be thrown by addNumConditionMock
-      const expectedError = createError(400, 'error for test');
+      const error = createError(400, 'error for test');
       mocks.addNumConditionMock.mockImplementation(() => {
-        throw expectedError;
+        throw error;
       });
 
       const resMock = mock<Response>();
@@ -127,7 +146,7 @@ describe('listPages', () => {
       expect(mocks.addNumConditionMock).toHaveBeenCalledOnce(); // throw an error
       expect(mocks.addSortConditionMock).not.toHaveBeenCalledOnce(); // does not called
       expect(resMock.status).toHaveBeenCalledOnce();
-      expect(resStatusMock.send).toHaveBeenCalledWith(expectedError);
+      expect(resStatusMock.send).toHaveBeenCalledWith('error for test');
     });
 
   });

+ 31 - 36
packages/remark-lsx/src/server/routes/list-pages/index.ts

@@ -1,10 +1,12 @@
 
-import type { IUser } from '@growi/core';
+import { type IUser, OptionParser } from '@growi/core';
 import { pathUtils } from '@growi/core/dist/utils';
 import escapeStringRegexp from 'escape-string-regexp';
 import type { Request, Response } from 'express';
 import createError, { isHttpError } from 'http-errors';
 
+import type { LsxApiParams, LsxApiResponseData } from '../../../interfaces/api';
+
 import { addDepthCondition } from './add-depth-condition';
 import { addNumCondition } from './add-num-condition';
 import { addSortCondition } from './add-sort-condition';
@@ -12,9 +14,6 @@ import { generateBaseQuery, type PageQuery } from './generate-base-query';
 import { getToppageViewersCount } from './get-toppage-viewers-count';
 
 
-const DEFAULT_PAGES_NUM = 50;
-
-
 const { addTrailingSlash } = pathUtils;
 
 /**
@@ -57,37 +56,25 @@ function addExceptCondition(query, pagePath, optionsFilter): PageQuery {
 }
 
 
-export type ListPagesOptions = {
-  depth?: string,
-  num?: string,
-  filter?: string,
-  except?: string,
-  sort?: string,
-  reverse?: string,
-}
-
 export const listPages = async(req: Request & { user: IUser }, res: Response): Promise<Response> => {
   const user = req.user;
 
-  let pagePath: string;
-  let options: ListPagesOptions | undefined;
-
-  try {
-    // TODO: use express-validator
-    if (req.query.pagePath == null) {
-      throw new Error("The 'pagePath' query must not be null.");
-    }
-
-    pagePath = req.query.pagePath?.toString();
-    if (req.query.options != null) {
-      options = JSON.parse(req.query.options.toString());
-    }
-  }
-  catch (error) {
-    return res.status(400).send(error);
+  // TODO: use express-validator
+  if (req.query.pagePath == null) {
+    return res.status(400).send("The 'pagePath' query must not be null.");
   }
 
-  const builder = await generateBaseQuery(pagePath, user);
+  const params: LsxApiParams = {
+    pagePath: req.query.pagePath.toString(),
+    offset: req.query?.offset != null ? Number(req.query.offset) : undefined,
+    limit: req.query?.limit != null ? Number(req.query?.limit) : undefined,
+    options: req.query?.options != null ? JSON.parse(req.query.options.toString()) : {},
+  };
+
+  const {
+    pagePath, offset, limit, options,
+  } = params;
+  const builder = await generateBaseQuery(params.pagePath, user);
 
   // count viewers of `/`
   let toppageViewersCount;
@@ -102,7 +89,7 @@ export const listPages = async(req: Request & { user: IUser }, res: Response): P
   try {
     // depth
     if (options?.depth != null) {
-      query = addDepthCondition(query, pagePath, options.depth);
+      query = addDepthCondition(query, params.pagePath, OptionParser.parseRange(options.depth));
     }
     // filter
     if (options?.filter != null) {
@@ -111,20 +98,28 @@ export const listPages = async(req: Request & { user: IUser }, res: Response): P
     if (options?.except != null) {
       query = addExceptCondition(query, pagePath, options.except);
     }
+
+    // get total num before adding num/sort conditions
+    const total = await query.clone().count();
+
     // num
-    const optionsNum = options?.num || DEFAULT_PAGES_NUM;
-    query = addNumCondition(query, optionsNum);
+    query = addNumCondition(query, offset, limit);
     // sort
     query = addSortCondition(query, options?.sort, options?.reverse);
 
     const pages = await query.exec();
-    return res.status(200).send({ pages, toppageViewersCount });
+    const cursor = (offset ?? 0) + pages.length;
+
+    const responseData: LsxApiResponseData = {
+      pages, cursor, total, toppageViewersCount,
+    };
+    return res.status(200).send(responseData);
   }
   catch (error) {
     if (isHttpError(error)) {
-      return res.status(error.status).send(error);
+      return res.status(error.status).send(error.message);
     }
-    return res.status(500).send(error);
+    return res.status(500).send(error.message);
   }
 
 };

+ 0 - 157
packages/remark-lsx/src/stores/lsx.tsx

@@ -1,157 +0,0 @@
-import * as url from 'url';
-
-import { IPageHasId, pathUtils } from '@growi/core';
-import axios from 'axios';
-import useSWR, { SWRResponse } from 'swr';
-
-import { LsxContext } from '../components/lsx-context';
-import type { PageNode } from '../interfaces/page-node';
-
-function isEquals(path1: string, path2: string) {
-  return pathUtils.removeTrailingSlash(path1) === pathUtils.removeTrailingSlash(path2);
-}
-
-function getParentPath(path: string) {
-  return pathUtils.addTrailingSlash(decodeURIComponent(url.resolve(path, '../')));
-}
-
-/**
- * generate PageNode instances for target page and the ancestors
- *
- * @param {any} pathToNodeMap
- * @param {any} rootPagePath
- * @param {any} pagePath
- * @returns
- * @memberof Lsx
- */
-function generatePageNode(pathToNodeMap: Record<string, PageNode>, rootPagePath: string, pagePath: string): PageNode | null {
-  // exclude rootPagePath itself
-  if (isEquals(pagePath, rootPagePath)) {
-    return null;
-  }
-
-  // return when already registered
-  if (pathToNodeMap[pagePath] != null) {
-    return pathToNodeMap[pagePath];
-  }
-
-  // generate node
-  const node = { pagePath, children: [] };
-  pathToNodeMap[pagePath] = node;
-
-  /*
-    * process recursively for ancestors
-    */
-  // get or create parent node
-  const parentPath = getParentPath(pagePath);
-  const parentNode = generatePageNode(pathToNodeMap, rootPagePath, parentPath);
-  // associate to patent
-  if (parentNode != null) {
-    parentNode.children.push(node);
-  }
-
-  return node;
-}
-
-function generatePageNodeTree(rootPagePath: string, pages: IPageHasId[]) {
-  const pathToNodeMap: Record<string, PageNode> = {};
-
-  pages.forEach((page) => {
-    // add slash ensure not to forward match to another page
-    // e.g. '/Java/' not to match to '/JavaScript'
-    const pagePath = pathUtils.addTrailingSlash(page.path);
-
-    const node = generatePageNode(pathToNodeMap, rootPagePath, pagePath); // this will not be null
-
-    // exclude rootPagePath itself
-    if (node == null) {
-      return;
-    }
-
-    // set the Page substance
-    node.page = page;
-  });
-
-  // return root objects
-  const rootNodes: PageNode[] = [];
-  Object.keys(pathToNodeMap).forEach((pagePath) => {
-    // exclude '/'
-    if (pagePath === '/') {
-      return;
-    }
-
-    const parentPath = getParentPath(pagePath);
-
-    // pick up what parent doesn't exist
-    if ((parentPath === '/') || !(parentPath in pathToNodeMap)) {
-      rootNodes.push(pathToNodeMap[pagePath]);
-    }
-  });
-  return rootNodes;
-}
-
-type LsxResponse = {
-  pages: IPageHasId[],
-  toppageViewersCount: number,
-}
-
-const useSWRxLsxResponse = (
-    pagePath: string, options?: Record<string, string | undefined>, isImmutable?: boolean,
-): SWRResponse<LsxResponse, Error> => {
-  return useSWR(
-    ['/_api/lsx', pagePath, options, isImmutable],
-    async([endpoint, pagePath, options]) => {
-      try {
-        const res = await axios.get<LsxResponse>(endpoint, {
-          params: {
-            pagePath,
-            options,
-          },
-        });
-        return res.data;
-      }
-      catch (err) {
-        if (axios.isAxiosError(err)) {
-          throw new Error(err.response?.data.message);
-        }
-        throw err;
-      }
-    },
-    {
-      keepPreviousData: true,
-      revalidateIfStale: !isImmutable,
-      revalidateOnFocus: !isImmutable,
-      revalidateOnReconnect: !isImmutable,
-    },
-  );
-};
-
-type LsxNodeTree = {
-  nodeTree: PageNode[],
-  toppageViewersCount: number,
-}
-
-export const useSWRxNodeTree = (lsxContext: LsxContext, isImmutable?: boolean): SWRResponse<LsxNodeTree, Error> => {
-  const {
-    data, error, isLoading, isValidating,
-  } = useSWRxLsxResponse(lsxContext.pagePath, lsxContext.options, isImmutable);
-
-  return useSWR(
-    !isLoading && !isValidating ? ['lsxNodeTree', lsxContext.pagePath, lsxContext.options, isImmutable, data, error] : null,
-    ([, pagePath, , , data]) => {
-      if (data === undefined || error != null) {
-        throw error;
-      }
-      return {
-        nodeTree: generatePageNodeTree(pagePath, data?.pages),
-        toppageViewersCount: data.toppageViewersCount,
-      };
-    },
-    {
-      keepPreviousData: true,
-      revalidateIfStale: !isImmutable,
-      revalidateOnFocus: !isImmutable,
-      revalidateOnReconnect: !isImmutable,
-    },
-  );
-};

+ 1 - 0
packages/remark-lsx/src/stores/lsx/index.ts

@@ -0,0 +1 @@
+export * from './lsx';

+ 61 - 0
packages/remark-lsx/src/stores/lsx/lsx.ts

@@ -0,0 +1,61 @@
+import axios from 'axios';
+import useSWRInfinite, { SWRInfiniteResponse } from 'swr/infinite';
+
+import type { LsxApiOptions, LsxApiParams, LsxApiResponseData } from '../../interfaces/api';
+
+import { parseNumOption } from './parse-num-option';
+
+
+const LOADMORE_PAGES_NUM = 10;
+
+
+export const useSWRxLsx = (
+    pagePath: string, options?: Record<string, string|undefined>, isImmutable?: boolean,
+): SWRInfiniteResponse<LsxApiResponseData, Error> => {
+
+  // parse num option
+  const initialOffsetAndLimit = options?.num != null
+    ? parseNumOption(options.num)
+    : null;
+
+  return useSWRInfinite(
+    (pageIndex, previousPageData) => {
+      if (previousPageData != null && previousPageData.pages.length === 0) return null;
+
+      // the first loading
+      if (pageIndex === 0 || previousPageData == null) {
+        return ['/_api/lsx', pagePath, options, initialOffsetAndLimit?.offset, initialOffsetAndLimit?.limit, isImmutable];
+      }
+
+      // loading more
+      return ['/_api/lsx', pagePath, options, previousPageData.cursor, LOADMORE_PAGES_NUM, isImmutable];
+    },
+    async([endpoint, pagePath, options, offset, limit]) => {
+      const apiOptions = Object.assign({}, options, { num: undefined }) as LsxApiOptions;
+      const params: LsxApiParams = {
+        pagePath,
+        offset,
+        limit,
+        options: apiOptions,
+      };
+      try {
+        const res = await axios.get<LsxApiResponseData>(endpoint, { params });
+        return res.data;
+      }
+      catch (err) {
+        if (axios.isAxiosError(err)) {
+          throw new Error(err.response?.data.message);
+        }
+        throw err;
+      }
+    },
+    {
+      keepPreviousData: true,
+      revalidateIfStale: !isImmutable,
+      revalidateOnFocus: !isImmutable,
+      revalidateOnReconnect: !isImmutable,
+      revalidateFirstPage: false,
+      revalidateAll: false,
+    },
+  );
+};

+ 77 - 0
packages/remark-lsx/src/stores/lsx/parse-num-option.spec.ts

@@ -0,0 +1,77 @@
+import { OptionParser } from '@growi/core/dist/plugin';
+
+import { parseNumOption } from './parse-num-option';
+
+describe('addNumCondition()', () => {
+
+  it('set limit with the specified number', () => {
+    // setup
+    const parseRangeSpy = vi.spyOn(OptionParser, 'parseRange');
+
+    // when
+    const result = parseNumOption('99');
+
+    // then
+    expect(result).toEqual({ limit: 99 });
+    expect(parseRangeSpy).not.toHaveBeenCalled();
+  });
+
+  it('returns null when the option value is invalid', () => {
+    // setup
+    const parseRangeSpy = vi.spyOn(OptionParser, 'parseRange');
+
+    // when
+    const result = parseNumOption('invalid string');
+
+    // then
+    expect(parseRangeSpy).toHaveBeenCalledWith('invalid string');
+    expect(result).toBeNull();
+  });
+
+  it('throws an error when the start value is smaller than 1', () => {
+    // setup
+    const parseRangeSpy = vi.spyOn(OptionParser, 'parseRange');
+
+    // when
+    const caller = () => parseNumOption('-1:10');
+
+    // then
+    expect(caller).toThrowError("The specified option 'num' is { start: -1, end: 10 } : the start must be larger or equal than 1");
+    expect(parseRangeSpy).toHaveBeenCalledWith('-1:10');
+  });
+
+  it('throws an error when the end value is smaller than the start value', () => {
+    // setup
+    const parseRangeSpy = vi.spyOn(OptionParser, 'parseRange');
+
+    // when
+    const caller = () => parseNumOption('3:2');
+
+    // then
+    expect(caller).toThrowError("The specified option 'num' is { start: 3, end: 2 } : the end must be larger or equal than the start");
+    expect(parseRangeSpy).toHaveBeenCalledWith('3:2');
+  });
+
+});
+
+
+describe('addNumCondition() set skip and limit with the range string', () => {
+
+  it.concurrent.each`
+    optionsNum    | expected
+    ${'1:10'}     | ${{ offset: 0, limit: 10 }}
+    ${'2:2'}      | ${{ offset: 1, limit: 1 }}
+    ${'3:'}       | ${{ offset: 2, limit: -1 }}
+  `("'$optionsNum", ({ optionsNum, expected }) => {
+    // setup
+    const parseRangeSpy = vi.spyOn(OptionParser, 'parseRange');
+
+    // when
+    const result = parseNumOption(optionsNum);
+
+    // then
+    expect(parseRangeSpy).toHaveBeenCalledWith(optionsNum);
+    expect(result).toEqual(expected);
+  });
+
+});

+ 36 - 0
packages/remark-lsx/src/stores/lsx/parse-num-option.ts

@@ -0,0 +1,36 @@
+import { OptionParser } from '@growi/core/dist/plugin';
+
+export type ParseNumOptionResult = { offset: number, limit?: number } | { offset?: number, limit: number };
+
+/**
+ * add num condition that limit fetched pages
+ */
+export const parseNumOption = (optionsNum: string): ParseNumOptionResult | null => {
+
+  if (Number.isInteger(Number(optionsNum))) {
+    return { limit: Number(optionsNum) };
+  }
+
+  const range = OptionParser.parseRange(optionsNum);
+
+  if (range == null) {
+    return null;
+  }
+
+  const start = range.start;
+  const end = range.end;
+
+  // check start
+  if (start < 1) {
+    throw new Error(`The specified option 'num' is { start: ${start}, end: ${end} } : the start must be larger or equal than 1`);
+  }
+  // check end
+  if (start > end && end > 0) {
+    throw new Error(`The specified option 'num' is { start: ${start}, end: ${end} } : the end must be larger or equal than the start`);
+  }
+
+  const offset = start - 1;
+  const limit = Math.max(-1, end - offset);
+
+  return { offset, limit };
+};

+ 8 - 0
packages/remark-lsx/src/utils/depth-utils.ts

@@ -0,0 +1,8 @@
+import { isTopPage } from '@growi/core/dist/utils/page-path-utils';
+
+export const getDepthOfPath = (path: string): number => {
+  if (isTopPage(path)) {
+    return 0;
+  }
+  return (path.match(/\//g) ?? []).length;
+};

+ 217 - 0
packages/remark-lsx/src/utils/page-node.spec.ts

@@ -0,0 +1,217 @@
+import { IPageHasId, OptionParser } from '@growi/core';
+import { mock } from 'vitest-mock-extended';
+
+import { PageNode } from '../interfaces/page-node';
+
+import { generatePageNodeTree } from './page-node';
+
+
+function omitPageData(pageNode: PageNode): Omit<PageNode, 'page'> {
+  const obj = Object.assign({}, pageNode);
+  delete obj.page;
+
+  // omit data in children
+  obj.children = obj.children.map(child => omitPageData(child));
+
+  return obj;
+}
+
+describe('generatePageNodeTree()', () => {
+
+  it("returns when the rootPagePath is '/'", () => {
+    // setup
+    const pages: IPageHasId[] = [
+      '/',
+      '/Sandbox',
+    ].map(path => mock<IPageHasId>({ path }));
+
+    // when
+    const result = generatePageNodeTree('/', pages);
+    const resultWithoutPageData = result.map(pageNode => omitPageData(pageNode));
+
+    // then
+    expect(resultWithoutPageData).toStrictEqual([
+      {
+        pagePath: '/Sandbox',
+        children: [],
+      },
+    ]);
+  });
+
+  it('returns when the pages are not empty', () => {
+    // setup
+    const pages: IPageHasId[] = [
+      '/Sandbox',
+      '/Sandbox/level2',
+      '/Sandbox/level2/level3-1',
+      '/Sandbox/level2/level3-2',
+      '/Sandbox/level2/level3-3',
+    ].map(path => mock<IPageHasId>({ path }));
+
+    // when
+    const result = generatePageNodeTree('/Sandbox', pages);
+    const resultWithoutPageData = result.map(pageNode => omitPageData(pageNode));
+
+    // then
+    expect(resultWithoutPageData).toStrictEqual([
+      {
+        pagePath: '/Sandbox/level2',
+        children: [
+          {
+            pagePath: '/Sandbox/level2/level3-1',
+            children: [],
+          },
+          {
+            pagePath: '/Sandbox/level2/level3-2',
+            children: [],
+          },
+          {
+            pagePath: '/Sandbox/level2/level3-3',
+            children: [],
+          },
+        ],
+      },
+    ]);
+  });
+
+  it('returns when the pages include some empty pages', () => {
+    // setup
+    const pages: IPageHasId[] = [
+      '/',
+      '/user/foo',
+      '/user/bar',
+      '/user/bar/memo/2023/06/01',
+      '/user/bar/memo/2023/06/02/memo-test',
+    ].map(path => mock<IPageHasId>({ path }));
+
+    // when
+    const result = generatePageNodeTree('/', pages);
+    const resultWithoutPageData = result.map(pageNode => omitPageData(pageNode));
+
+    // then
+    expect(resultWithoutPageData).toStrictEqual([
+      {
+        pagePath: '/user',
+        children: [
+          {
+            pagePath: '/user/foo',
+            children: [],
+          },
+          {
+            pagePath: '/user/bar',
+            children: [
+              {
+                pagePath: '/user/bar/memo',
+                children: [
+                  {
+                    pagePath: '/user/bar/memo/2023',
+                    children: [
+                      {
+                        pagePath: '/user/bar/memo/2023/06',
+                        children: [
+                          {
+                            pagePath: '/user/bar/memo/2023/06/01',
+                            children: [],
+                          },
+                          {
+                            pagePath: '/user/bar/memo/2023/06/02',
+                            children: [
+                              {
+                                pagePath: '/user/bar/memo/2023/06/02/memo-test',
+                                children: [],
+                              },
+                            ],
+                          },
+                        ],
+                      },
+                    ],
+                  },
+                ],
+              },
+            ],
+          },
+        ],
+      },
+    ]);
+  });
+
+  it("returns with 'depth=1:2'", () => {
+    // setup
+    const pages: IPageHasId[] = [
+      '/Sandbox',
+      '/Sandbox/level2-1',
+      '/Sandbox/level2-2',
+      '/user',
+      '/user/foo',
+      '/user/bar',
+    ].map(path => mock<IPageHasId>({ path }));
+
+    // when
+    const depthRange = OptionParser.parseRange('1:2');
+    const result = generatePageNodeTree('/', pages, depthRange);
+    const resultWithoutPageData = result.map(pageNode => omitPageData(pageNode));
+
+    // then
+    expect(resultWithoutPageData).toStrictEqual([
+      {
+        pagePath: '/Sandbox',
+        children: [
+          {
+            pagePath: '/Sandbox/level2-1',
+            children: [],
+          },
+          {
+            pagePath: '/Sandbox/level2-2',
+            children: [],
+          },
+        ],
+      },
+      {
+        pagePath: '/user',
+        children: [
+          {
+            pagePath: '/user/foo',
+            children: [],
+          },
+          {
+            pagePath: '/user/bar',
+            children: [],
+          },
+        ],
+      },
+    ]);
+  });
+
+  it("returns with 'depth=2:3'", () => {
+    // setup
+    const pages: IPageHasId[] = [
+      '/foo/level2',
+      '/foo/level2',
+      '/foo/level2/level3-1',
+      '/foo/level2/level3-2',
+    ].map(path => mock<IPageHasId>({ path }));
+
+    // when
+    const depthRange = OptionParser.parseRange('2:3');
+    const result = generatePageNodeTree('/', pages, depthRange);
+    const resultWithoutPageData = result.map(pageNode => omitPageData(pageNode));
+
+    // then
+    expect(resultWithoutPageData).toStrictEqual([
+      {
+        pagePath: '/foo/level2',
+        children: [
+          {
+            pagePath: '/foo/level2/level3-1',
+            children: [],
+          },
+          {
+            pagePath: '/foo/level2/level3-2',
+            children: [],
+          },
+        ],
+      },
+    ]);
+  });
+
+});

+ 91 - 0
packages/remark-lsx/src/utils/page-node.ts

@@ -0,0 +1,91 @@
+import * as url from 'url';
+
+import type { IPageHasId, ParseRangeResult } from '@growi/core';
+import { removeTrailingSlash } from '@growi/core/dist/utils/path-utils';
+
+import type { PageNode } from '../interfaces/page-node';
+
+import { getDepthOfPath } from './depth-utils';
+
+
+function getParentPath(path: string) {
+  return removeTrailingSlash(decodeURIComponent(url.resolve(path, './')));
+}
+
+/**
+ * generate PageNode instances for target page and the ancestors
+ *
+ * @param {any} pathToNodeMap
+ * @param {any} rootPagePath
+ * @param {any} pagePath
+ * @returns
+ * @memberof Lsx
+ */
+function generatePageNode(
+    pathToNodeMap: Record<string, PageNode>, rootPagePath: string, pagePath: string, depthRange?: ParseRangeResult | null,
+): PageNode | null {
+
+  // exclude rootPagePath itself
+  if (pagePath === rootPagePath) {
+    return null;
+  }
+
+  const depthStartToProcess = getDepthOfPath(rootPagePath) + (depthRange?.start ?? 0); // at least 1
+  const currentPageDepth = getDepthOfPath(pagePath);
+
+  // return by the depth restriction
+  // '/' will also return null because the depth is 0
+  if (currentPageDepth < depthStartToProcess) {
+    return null;
+  }
+
+  // return when already registered
+  if (pathToNodeMap[pagePath] != null) {
+    return pathToNodeMap[pagePath];
+  }
+
+  // generate node
+  const node = { pagePath, children: [] };
+  pathToNodeMap[pagePath] = node;
+
+  /*
+    * process recursively for ancestors
+    */
+  // get or create parent node
+  const parentPath = getParentPath(pagePath);
+  const parentNode = generatePageNode(pathToNodeMap, rootPagePath, parentPath, depthRange);
+  // associate to patent
+  if (parentNode != null) {
+    parentNode.children.push(node);
+  }
+
+  return node;
+}
+
+export function generatePageNodeTree(rootPagePath: string, pages: IPageHasId[], depthRange?: ParseRangeResult | null): PageNode[] {
+  const pathToNodeMap: Record<string, PageNode> = {};
+
+  pages.forEach((page) => {
+    const node = generatePageNode(pathToNodeMap, rootPagePath, page.path, depthRange); // this will not be null
+
+    // exclude rootPagePath itself
+    if (node == null) {
+      return;
+    }
+
+    // set the Page substance
+    node.page = page;
+  });
+
+  // return root objects
+  const rootNodes: PageNode[] = [];
+  Object.keys(pathToNodeMap).forEach((pagePath) => {
+    const parentPath = getParentPath(pagePath);
+
+    // pick up what parent doesn't exist
+    if ((parentPath === '/') || !(parentPath in pathToNodeMap)) {
+      rootNodes.push(pathToNodeMap[pagePath]);
+    }
+  });
+  return rootNodes;
+}

+ 1 - 0
packages/remark-lsx/vite.client.config.ts

@@ -29,6 +29,7 @@ export default defineConfig({
         'next/link',
         'unified',
         'swr',
+        /^swr\/.*/,
         /^hast-.*/,
         /^unist-.*/,
         /^@growi\/.*/,