2
0
ryoji-s 3 жил өмнө
parent
commit
0a6e36ab35

+ 3 - 4
packages/app/src/components/Sidebar/InfiniteScroll.tsx → packages/app/src/components/InfiniteScroll.tsx

@@ -5,11 +5,11 @@ import React, {
 import type { SWRInfiniteResponse } from 'swr/infinite';
 
 type Props<T> = {
-  swrInifiniteResponse : SWRInfiniteResponse<T>
+  swrInifiniteResponse: SWRInfiniteResponse<T>
   children: React.ReactNode,
   loadingIndicator?: React.ReactNode
   endingIndicator?: React.ReactNode
-  isReachingEnd?: boolean,
+  isReachingEnd?: boolean
   offset?: number
 }
 
@@ -59,8 +59,7 @@ const InfiniteScroll = <E, >(props: Props<E>): React.ReactElement<Props<E>> => {
 
   return (
     <>
-      { children }
-
+      {children}
       <div style={{ position: 'relative' }}>
         <div ref={ref} style={{ position: 'absolute', top: offset }}></div>
         {isReachingEnd

+ 28 - 3
packages/app/src/components/PageAccessoriesModal.tsx

@@ -17,7 +17,7 @@ import AttachmentIcon from './Icons/AttachmentIcon';
 import HistoryIcon from './Icons/HistoryIcon';
 import ShareLinkIcon from './Icons/ShareLinkIcon';
 import PageAttachment from './PageAttachment';
-import { PageHistory } from './PageHistory';
+import { PageHistory, getQueryParam } from './PageHistory';
 import ShareLink from './ShareLink/ShareLink';
 
 import styles from './PageAccessoriesModal.module.scss';
@@ -27,6 +27,9 @@ const PageAccessoriesModal = (): JSX.Element => {
   const { t } = useTranslation();
 
   const [activeTab, setActiveTab] = useState<PageAccessoriesModalContents>(PageAccessoriesModalContents.PageHistory);
+  const [sourceRevisionId, setSourceRevisionId] = useState<string>();
+  const [targetRevisionId, setTargetRevisionId] = useState<string>();
+
   const [isWindowExpanded, setIsWindowExpanded] = useState(false);
 
   const { data: isSharedUser } = useIsSharedUser();
@@ -48,6 +51,28 @@ const PageAccessoriesModal = (): JSX.Element => {
     }, false);
   }, [mutate, status]);
 
+  // Set sourceRevisionId and targetRevisionId as state with valid object id string
+  useEffect(() => {
+    const queryParams = getQueryParam();
+    // https://regex101.com/r/YHTDsr/1
+    const regex = /^([0-9a-f]{24})...([0-9a-f]{24})$/i;
+
+    if (queryParams == null || !regex.test(queryParams)) {
+      return;
+    }
+
+    const matches = queryParams.match(regex);
+
+    if (matches == null) {
+      return;
+    }
+
+    const [, sourceRevisionId, targetRevisionId] = matches;
+    setSourceRevisionId(sourceRevisionId);
+    setTargetRevisionId(targetRevisionId);
+    mutate({ isOpened: true });
+  }, [mutate]);
+
   const navTabMapping = useMemo(() => {
     const isOpened = status == null ? false : status.isOpened;
     return {
@@ -57,7 +82,7 @@ const PageAccessoriesModal = (): JSX.Element => {
           if (!isOpened) {
             return <></>;
           }
-          return <PageHistory onClose={close}/>;
+          return <PageHistory onClose={close} sourceRevisionId={sourceRevisionId} targetRevisionId={targetRevisionId}/>;
         },
         i18n: t('History'),
         index: 0,
@@ -87,7 +112,7 @@ const PageAccessoriesModal = (): JSX.Element => {
         isLinkEnabled: () => !isGuestUser && !isSharedUser && !isLinkSharingDisabled,
       },
     };
-  }, [status, t, close, isGuestUser, isSharedUser, isLinkSharingDisabled]);
+  }, [status, t, close, sourceRevisionId, targetRevisionId, isGuestUser, isSharedUser, isLinkSharingDisabled]);
 
   const buttons = useMemo(() => (
     <div className="d-flex flex-nowrap">

+ 9 - 8
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -11,10 +11,9 @@ import { throttle, debounce } from 'throttle-debounce';
 import urljoin from 'url-join';
 
 import InterceptorManager from '~/services/interceptor-manager';
-import { useHandsontableModal, useDrawioModal } from '~/stores/modal';
+import { useHandsontableModal, useDrawioModal, useTemplateModal } from '~/stores/modal';
 import loggerFactory from '~/utils/logger';
 
-import { TemplateModal } from '../TemplateModal';
 import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
 
 import AbstractEditor from './AbstractEditor';
@@ -873,7 +872,8 @@ class CodeMirrorEditor extends AbstractEditor {
   }
 
   showTemplateModal() {
-    this.setState({ isTemplateModalOpened: true });
+    const onSubmit = templateText => this.setValue(templateText);
+    this.props.onClickTemplateBtn(onSubmit);
   }
 
   // fold draw.io section (``` drawio ~ ```)
@@ -1158,11 +1158,6 @@ class CodeMirrorEditor extends AbstractEditor {
           ref={this.linkEditModal}
           onSave={(linkText) => { return markdownLinkUtil.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), linkText) }}
         />
-        <TemplateModal
-          isOpen={this.state.isTemplateModalOpened}
-          onClose={() => this.setState({ isTemplateModalOpened: false })}
-          onSubmit={templateText => this.setValue(templateText) }
-        />
       </div>
     );
   }
@@ -1187,6 +1182,7 @@ const CodeMirrorEditorMemoized = memo(CodeMirrorEditor);
 const CodeMirrorEditorFc = React.forwardRef((props, ref) => {
   const { open: openDrawioModal } = useDrawioModal();
   const { open: openHandsontableModal } = useHandsontableModal();
+  const { open: openTemplateModal } = useTemplateModal();
 
   const openDrawioModalHandler = useCallback((drawioMxFile, onSave) => {
     openDrawioModal(drawioMxFile, onSave);
@@ -1196,11 +1192,16 @@ const CodeMirrorEditorFc = React.forwardRef((props, ref) => {
     openHandsontableModal(markdownTable, editor, autoFormatMarkdownTable);
   }, [openHandsontableModal]);
 
+  const openTemplateModalHandler = useCallback((onSubmit) => {
+    openTemplateModal(onSubmit);
+  }, [openTemplateModal]);
+
   return (
     <CodeMirrorEditorMemoized
       ref={ref}
       onClickDrawioBtn={openDrawioModalHandler}
       onClickTableBtn={openTableModalHandler}
+      onClickTemplateBtn={openTemplateModalHandler}
       {...props}
     />
   );

+ 24 - 67
packages/app/src/components/PageHistory.tsx

@@ -1,85 +1,42 @@
-import React, { useState, useEffect } from 'react';
-
-import { IRevisionHasPageId } from '@growi/core';
+import React from 'react';
 
 import { useCurrentPageId } from '~/stores/context';
-import { useSWRxPageRevisions, useCurrentPagePath } from '~/stores/page';
+import { useCurrentPagePath } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 
 import { PageRevisionTable } from './PageHistory/PageRevisionTable';
-import PaginationWrapper from './PaginationWrapper';
-import { RevisionComparer } from './RevisionComparer/RevisionComparer';
 
 const logger = loggerFactory('growi:PageHistory');
 
-export const PageHistory: React.FC<{ onClose: () => void }> = ({ onClose }) => {
+type PageHistoryProps = {
+  sourceRevisionId?: string,
+  targetRevisionId?: string
+  onClose: () => void
+}
+
+// Get string from 'compare' query params
+export const getQueryParam = (): string | null => {
+  const query: URLSearchParams = new URL(window.location.href).searchParams;
+  return query.get('compare');
+};
 
-  const [activePage, setActivePage] = useState(1);
+export const PageHistory: React.FC<PageHistoryProps> = (props: PageHistoryProps) => {
+  const { sourceRevisionId, targetRevisionId, onClose } = props;
 
   const { data: currentPageId } = useCurrentPageId();
   const { data: currentPagePath } = useCurrentPagePath();
 
-  const { data: revisionsData, mutate: mutatePageRevisions } = useSWRxPageRevisions(activePage, 10, currentPageId);
-
-  const [sourceRevision, setSourceRevision] = useState<IRevisionHasPageId>();
-  const [targetRevision, setTargetRevision] = useState<IRevisionHasPageId>();
-
-  useEffect(() => {
-    if (revisionsData != null) {
-      setSourceRevision(revisionsData.revisions[0]);
-      setTargetRevision(revisionsData.revisions[0]);
-    }
-  }, [revisionsData]);
-
-  useEffect(() => {
-    mutatePageRevisions();
-  });
-
-  const pagingLimit = 10;
-
-  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>
-    );
-  }
-
-  const pager = () => {
-    return (
-      <PaginationWrapper
-        activePage={activePage}
-        changePage={setActivePage}
-        totalItemsCount={revisionsData.totalCounts}
-        pagingLimit={pagingLimit}
-        align="center"
-      />
-    );
-  };
-
   return (
     <div className="revision-history" data-testid="page-history">
-      <PageRevisionTable
-        revisions={revisionsData.revisions}
-        pagingLimit={pagingLimit}
-        sourceRevision={sourceRevision}
-        targetRevision={targetRevision}
-        currentPageId={currentPageId}
-        currentPagePath={currentPagePath}
-        onChangeSourceInvoked={setSourceRevision}
-        onChangeTargetInvoked={setTargetRevision}
-        onClose={onClose}
-      />
-      <div className="my-3">
-        {pager()}
-      </div>
-      <RevisionComparer
-        sourceRevision={sourceRevision}
-        targetRevision={targetRevision}
-        currentPageId={currentPageId}
-        currentPagePath={currentPagePath}
-        onClose={onClose}
-      />
+      {currentPageId != null && currentPagePath != null && (
+        <PageRevisionTable
+          sourceRevisionId={sourceRevisionId}
+          targetRevisionId={targetRevisionId}
+          currentPageId={currentPageId}
+          currentPagePath={currentPagePath}
+          onClose={onClose}
+        />
+      )}
     </div>
   );
 };

+ 128 - 49
packages/app/src/components/PageHistory/PageRevisionTable.tsx

@@ -1,37 +1,110 @@
-import React from 'react';
+import React, {
+  useEffect, useRef, useState,
+} from 'react';
 
-import { IRevisionHasId } from '@growi/core';
+import { IRevisionHasId, IRevisionHasPageId } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 
+import { useSWRxInfinitePageRevisions } from '~/stores/page';
+
+import { RevisionComparer } from '../RevisionComparer/RevisionComparer';
+
 import { Revision } from './Revision';
 
 import styles from './PageRevisionTable.module.scss';
 
-type PageRevisionTAble = {
-  revisions: IRevisionHasId[],
-  pagingLimit: number,
-  sourceRevision: IRevisionHasId,
-  targetRevision: IRevisionHasId,
-  currentPageId: string,
-  currentPagePath: string,
-  onChangeSourceInvoked: React.Dispatch<React.SetStateAction<IRevisionHasId | undefined>>,
-  onChangeTargetInvoked: React.Dispatch<React.SetStateAction<IRevisionHasId | undefined>>,
+type PageRevisionTableProps = {
+  sourceRevisionId?: string
+  targetRevisionId?: string
   onClose: () => void,
+  currentPageId: string
+  currentPagePath: string
 }
 
-export const PageRevisionTable = (props: PageRevisionTAble): JSX.Element => {
+export const PageRevisionTable = (props: PageRevisionTableProps): JSX.Element => {
   const { t } = useTranslation();
 
+  const REVISIONS_PER_PAGE = 10;
+
   const {
-    revisions, pagingLimit, sourceRevision, targetRevision, currentPageId, currentPagePath,
-    onChangeSourceInvoked, onChangeTargetInvoked, onClose,
+    sourceRevisionId, targetRevisionId, onClose, currentPageId, currentPagePath,
   } = props;
 
-  const revisionCount = revisions.length;
-  const latestRevision = revisions[0];
-  const oldestRevision = revisions[revisions.length - 1];
+  // Load all data if source revision id and target revision id not null
+  const revisionPerPage = (sourceRevisionId != null && targetRevisionId != null) ? 0 : REVISIONS_PER_PAGE;
+  const swrInifiniteResponse = useSWRxInfinitePageRevisions(currentPageId, revisionPerPage);
+
 
-  const renderRow = (revision: IRevisionHasId, previousRevision: IRevisionHasId, latestRevision: IRevisionHasId,
+  const {
+    data, size, error, setSize, isValidating,
+  } = swrInifiniteResponse;
+
+  const revisions = data && data[0].revisions;
+  const oldestRevision = revisions && revisions[revisions.length - 1];
+
+  // First load
+  const isLoadingInitialData = !data && !error;
+  const isLoadingMore = isLoadingInitialData
+    || (isValidating && data != null && typeof data[size - 1] === 'undefined');
+  const isReachingEnd = (revisionPerPage === 0) || !!(data != null && data[data.length - 1]?.revisions.length < REVISIONS_PER_PAGE);
+
+  const [sourceRevision, setSourceRevision] = useState<IRevisionHasPageId>();
+  const [targetRevision, setTargetRevision] = useState<IRevisionHasPageId>();
+
+  const tbodyRef = useRef<HTMLTableSectionElement>(null);
+
+
+  useEffect(() => {
+    if (revisions != null) {
+      if (sourceRevisionId != null && targetRevisionId != null) {
+        const sourceRevision = revisions.filter(revision => revision._id === sourceRevisionId)[0];
+        const targetRevision = revisions.filter(revision => revision._id === targetRevisionId)[0];
+        setSourceRevision(sourceRevision);
+        setTargetRevision(targetRevision);
+      }
+      else {
+        const latestRevision = revisions != null ? revisions[0] : null;
+        if (latestRevision != null) {
+          setSourceRevision(latestRevision);
+          setTargetRevision(latestRevision);
+        }
+      }
+    }
+  }, [revisions, sourceRevisionId, targetRevisionId]);
+
+  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);
+  };
+  const onChangeTargetInvoked: React.Dispatch<React.SetStateAction<IRevisionHasId | undefined>> = (revision: IRevisionHasPageId) => {
+    setTargetRevision(revision);
+  };
+
+
+  const renderRow = (revision: IRevisionHasPageId, previousRevision: IRevisionHasPageId, latestRevision: IRevisionHasPageId,
       isOldestRevision: boolean, hasDiff: boolean) => {
 
     const revisionId = revision._id;
@@ -52,10 +125,10 @@ export const PageRevisionTable = (props: PageRevisionTAble): JSX.Element => {
           <div className="d-lg-flex">
             <Revision
               revision={revision}
-              currentPageId={currentPageId}
-              currentPagePath={currentPagePath}
               isLatestRevision={revision === latestRevision}
               hasDiff={hasDiff}
+              currentPageId={currentPageId}
+              currentPagePath={currentPagePath}
               key={`revision-history-rev-${revisionId}`}
               onClose={onClose}
             />
@@ -118,36 +191,42 @@ export const PageRevisionTable = (props: PageRevisionTAble): JSX.Element => {
     );
   };
 
-  const revisionList = revisions.map((revision, idx) => {
-    // Returns null because the last revision is for the bottom diff display
-    if (idx === pagingLimit) {
-      return null;
-    }
-
-    // if it is the first revision, show full text as diff text
-    const previousRevision = (idx + 1 < revisionCount) ? revisions[idx + 1] : revision;
-
-    const isOldestRevision = revision === oldestRevision;
-
-    // set 'true' if undefined for backward compatibility
-    const hasDiff = revision.hasDiffToPrev !== false;
-
-    return renderRow(revision, previousRevision, latestRevision, isOldestRevision, hasDiff);
-  });
-
   return (
-    <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">
-        {revisionList}
-      </tbody>
-    </table>
+    <>
+      <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 != null && 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 != null && targetRevision != null && (
+        <RevisionComparer
+          sourceRevision={sourceRevision}
+          targetRevision={targetRevision}
+          currentPageId={currentPageId}
+          currentPagePath={currentPagePath}
+          onClose={onClose}
+        />)
+      }
+    </>
   );
 
 };

+ 3 - 3
packages/app/src/components/PageHistory/Revision.tsx

@@ -13,10 +13,10 @@ import styles from './Revision.module.scss';
 
 type RevisionProps = {
   revision: IRevisionHasId,
-  currentPageId: string,
-  currentPagePath: string,
   isLatestRevision: boolean,
   hasDiff: boolean,
+  currentPageId: string
+  currentPagePath: string
   onClose: () => void,
 }
 
@@ -24,7 +24,7 @@ export const Revision = (props: RevisionProps): JSX.Element => {
   const { t } = useTranslation();
 
   const {
-    revision, currentPageId, currentPagePath, isLatestRevision, hasDiff, onClose,
+    revision, isLatestRevision, hasDiff, onClose, currentPageId, currentPagePath,
   } = props;
 
   const { returnPathForURL } = pathUtils;

+ 2 - 1
packages/app/src/components/RevisionComparer/RevisionComparer.tsx

@@ -32,11 +32,12 @@ export const RevisionComparer = (props: RevisionComparerProps): JSX.Element => {
   const { t } = useTranslation(['translation', 'commons']);
 
   const {
-    sourceRevision, targetRevision, currentPageId, currentPagePath, onClose,
+    sourceRevision, targetRevision, onClose, currentPageId, currentPagePath,
   } = props;
 
   const [dropdownOpen, setDropdownOpen] = useState(false);
 
+
   const toggleDropdown = () => {
     setDropdownOpen(!dropdownOpen);
   };

+ 1 - 1
packages/app/src/components/Sidebar/RecentChanges.tsx

@@ -14,8 +14,8 @@ import { useSWRINFxRecentlyUpdated } from '~/stores/page-listing';
 import loggerFactory from '~/utils/logger';
 
 import FormattedDistanceDate from '../FormattedDistanceDate';
+import InfiniteScroll from '../InfiniteScroll';
 
-import InfiniteScroll from './InfiniteScroll';
 import { SidebarHeaderReloadButton } from './SidebarHeaderReloadButton';
 import RecentChangesContentSkeleton from './Skeleton/RecentChangesContentSkeleton';
 

+ 13 - 18
packages/app/src/components/TemplateModal.tsx

@@ -9,6 +9,7 @@ import {
   ModalFooter,
 } from 'reactstrap';
 
+import { useTemplateModal } from '~/stores/modal';
 import { usePreviewOptions } from '~/stores/renderer';
 import { useTemplates } from '~/stores/template';
 
@@ -40,17 +41,10 @@ const TemplateRadioButton = ({ template, onChange, isSelected }: TemplateRadioBu
   );
 };
 
-
-type Props = {
-  isOpen: boolean,
-  onClose: () => void,
-  onSubmit?: (markdown: string) => void,
-}
-
-export const TemplateModal = (props: Props): JSX.Element => {
+export const TemplateModal = (): JSX.Element => {
   const { t } = useTranslation();
 
-  const { isOpen, onClose, onSubmit } = props;
+  const { data: templateModalStatus, close } = useTemplateModal();
 
   const { data: rendererOptions } = usePreviewOptions();
   const { data: templates } = useTemplates();
@@ -58,22 +52,23 @@ export const TemplateModal = (props: Props): JSX.Element => {
   const [selectedTemplate, setSelectedTemplate] = useState<ITemplate>();
 
   const submitHandler = useCallback((template?: ITemplate) => {
-    if (onSubmit == null || template == null) {
-      onClose();
+    if (templateModalStatus == null) { return }
+    if (templateModalStatus.onSubmit == null || template == null) {
+      close();
       return;
     }
 
-    onSubmit(template.markdown);
-    onClose();
-  }, [onClose, onSubmit]);
+    templateModalStatus.onSubmit(template.markdown);
+    close();
+  }, [close, templateModalStatus]);
 
-  if (templates == null) {
+  if (templates == null || templateModalStatus == null) {
     return <></>;
   }
 
   return (
-    <Modal className="link-edit-modal" isOpen={isOpen} toggle={onClose} size="lg" autoFocus={false}>
-      <ModalHeader tag="h4" toggle={onClose} className="bg-primary text-light">
+    <Modal className="link-edit-modal" isOpen={templateModalStatus.isOpened} toggle={close} size="lg" autoFocus={false}>
+      <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
         Template
       </ModalHeader>
 
@@ -105,7 +100,7 @@ export const TemplateModal = (props: Props): JSX.Element => {
 
       </ModalBody>
       <ModalFooter>
-        <button type="button" className="btn btn-sm btn-outline-secondary mx-1" onClick={onClose}>
+        <button type="button" className="btn btn-sm btn-outline-secondary mx-1" onClick={close}>
           {t('Cancel')}
         </button>
         <button type="submit" className="btn btn-sm btn-primary mx-1" onClick={() => submitHandler(selectedTemplate)} disabled={selectedTemplate == null}>

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

@@ -74,6 +74,7 @@ const GrowiSubNavigationSwitcher = dynamic<GrowiSubNavigationSwitcherProps>(() =
   .then(mod => mod.GrowiSubNavigationSwitcher), { ssr: false });
 const DrawioModal = dynamic(() => import('../components/PageEditor/DrawioModal').then(mod => mod.DrawioModal), { ssr: false });
 const HandsontableModal = dynamic(() => import('../components/PageEditor/HandsontableModal').then(mod => mod.HandsontableModal), { ssr: false });
+const TemplateModal = dynamic(() => import('../components/TemplateModal').then(mod => mod.TemplateModal), { ssr: false });
 const PageStatusAlert = dynamic(() => import('../components/PageStatusAlert').then(mod => mod.PageStatusAlert), { ssr: false });
 
 const logger = loggerFactory('growi:pages:all');
@@ -355,6 +356,7 @@ Page.getLayout = function getLayout(page: React.ReactElement<Props>) {
       <DrawioModal />
       <HandsontableModal />
       <QuestionnaireModalManager />
+      <TemplateModal />
     </>
   );
 };

+ 21 - 10
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))) {
@@ -124,15 +123,21 @@ module.exports = (crowi) => {
 
     try {
       const page = await Page.findOne({ _id: pageId });
+      const queryOpts = {
+        offset,
+        sort: { createdAt: -1 },
+        populate: 'author',
+        pagination: false,
+      };
+
+      if (limit > 0) {
+        queryOpts.limit = limit;
+        queryOpts.pagination = true;
+      }
 
       const paginateResult = await Revision.paginate(
         { pageId: page._id },
-        {
-          page: selectedPage,
-          limit,
-          sort: { createdAt: -1 },
-          populate: 'author',
-        },
+        queryOpts,
       );
 
       paginateResult.docs.forEach((doc) => {
@@ -141,7 +146,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';

+ 4 - 1
packages/app/src/server/routes/attachment.js

@@ -468,7 +468,10 @@ module.exports = function(crowi, app) {
     if (pageId == null) {
       logger.debug('Create page before file upload');
 
-      page = await crowi.pageService.create(pagePath, `# ${pagePath}`, req.user, { grant: Page.GRANT_OWNER });
+      const isAclEnabled = crowi.aclService.isAclEnabled();
+      const grant = isAclEnabled ? Page.GRANT_OWNER : Page.GRANT_PUBLIC;
+
+      page = await crowi.pageService.create(pagePath, `# ${pagePath}`, req.user, { grant });
       pageCreated = true;
       pageId = page._id;
     }

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

@@ -617,3 +617,31 @@ export const useQuestionnaireModal = (status?: QuestionnaireModalStatuses): SWRR
     },
   };
 };
+
+/*
+ * TemplateModal
+ */
+type TemplateModalStatus = {
+  isOpened: boolean,
+  onSubmit?: (templateText: string) => void
+}
+
+type TemplateModalUtils = {
+  open(onSubmit: (templateText: string) => void): void,
+  close(): void,
+}
+
+export const useTemplateModal = (): SWRResponse<TemplateModalStatus, Error> & TemplateModalUtils => {
+
+  const initialStatus: TemplateModalStatus = { isOpened: false };
+  const swrResponse = useStaticSWR<TemplateModalStatus, Error>('templateModal', undefined, { fallbackData: initialStatus });
+
+  return Object.assign(swrResponse, {
+    open: (onSubmit: (templateText: string) => void) => {
+      swrResponse.mutate({ isOpened: true, onSubmit });
+    },
+    close: () => {
+      swrResponse.mutate({ isOpened: false });
+    },
+  });
+};

+ 25 - 18
packages/app/src/stores/page.tsx

@@ -1,11 +1,12 @@
-import { useEffect, useMemo, useCallback } from 'react';
+import { useEffect, useMemo } from 'react';
 
 import type {
-  IPageInfoForEntity, IPagePopulatedToShowRevision, Nullable,
+  IPageInfoForEntity, IPagePopulatedToShowRevision, Nullable, SWRInfinitePageRevisionsResponse,
 } from '@growi/core';
 import { Ref, isClient, pagePathUtils } from '@growi/core';
 import useSWR, { mutate, SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
+import useSWRInfinite, { SWRInfiniteResponse } from 'swr/infinite';
 import useSWRMutation, { SWRMutationResponse } from 'swr/mutation';
 
 import { apiGet } from '~/client/util/apiv1-client';
@@ -14,7 +15,7 @@ import {
   IPageInfo, IPageInfoForOperation,
 } from '~/interfaces/page';
 import { IRecordApplicableGrant, IResIsGrantNormalized } from '~/interfaces/page-grant';
-import { IRevision, IRevisionHasId, IRevisionsForPagination } from '~/interfaces/revision';
+import { IRevision, IRevisionHasId } from '~/interfaces/revision';
 
 import { IPageTagsInfo } from '../interfaces/tag';
 
@@ -143,22 +144,28 @@ export const useSWRxPageRevision = (pageId: string, revisionId: Ref<IRevision>):
   );
 };
 
-export const useSWRxPageRevisions = (
-    page: number, // page number of pagination
-    limit: number, // max number of pages in one paginate
-    pageId: string | null | undefined,
-): SWRResponse<IRevisionsForPagination, Error> => {
+/*
+ * SWR Infinite for page revision list
+ */
 
-  return useSWRImmutable(
-    ['/revisions/list', pageId, page, limit],
-    ([endpoint, pageId, page, limit]) => {
-      return apiv3Get(endpoint, { pageId, page, limit }).then((response) => {
-        const revisions = {
-          revisions: response.data.docs,
-          totalCounts: response.data.totalDocs,
-        };
-        return revisions;
-      });
+export const useSWRxInfinitePageRevisions = (
+    pageId: string,
+    limit: number,
+): SWRInfiniteResponse<SWRInfinitePageRevisionsResponse, Error> => {
+  return useSWRInfinite(
+    (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: true,
+      revalidateAll: false,
     },
   );
 };

+ 6 - 0
packages/core/src/interfaces/revision.ts

@@ -32,3 +32,9 @@ export type IRevisionOnConflict = {
 export type HasRevisionShortbody = {
   revisionShortBody?: string,
 }
+
+export type SWRInfinitePageRevisionsResponse = {
+  revisions: IRevisionHasPageId[],
+  totalCount: number,
+  offset: number,
+}