Przeglądaj źródła

Merge branch 'support/apply-nextjs-2' into imprv/emoji-picker-performance

Taichi Masuyama 3 lat temu
rodzic
commit
198678f7dc

+ 10 - 2
packages/app/src/components/Common/ClosableTextInput.tsx

@@ -30,7 +30,8 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
 
 
   const [inputText, setInputText] = useState(props.value);
   const [inputText, setInputText] = useState(props.value);
   const [currentAlertInfo, setAlertInfo] = useState<AlertInfo | null>(null);
   const [currentAlertInfo, setAlertInfo] = useState<AlertInfo | null>(null);
-  const [isAbleToShowAlert, setIsAbleToShowAlert] = useState<boolean>(false);
+  const [isAbleToShowAlert, setIsAbleToShowAlert] = useState(false);
+  const [isComposing, setComposing] = useState(false);
 
 
   const createValidation = async(inputText: string) => {
   const createValidation = async(inputText: string) => {
     if (props.inputValidator != null) {
     if (props.inputValidator != null) {
@@ -63,6 +64,10 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
   const onKeyDownHandler = (e) => {
   const onKeyDownHandler = (e) => {
     switch (e.key) {
     switch (e.key) {
       case 'Enter':
       case 'Enter':
+        // Do nothing when composing
+        if (isComposing) {
+          return;
+        }
         onPressEnter();
         onPressEnter();
         break;
         break;
       default:
       default:
@@ -107,7 +112,7 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
 
 
 
 
   return (
   return (
-    <div className="d-block flex-fill">
+    <div>
       <input
       <input
         value={inputText || ''}
         value={inputText || ''}
         ref={inputRef}
         ref={inputRef}
@@ -115,9 +120,12 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
         className="form-control"
         className="form-control"
         placeholder={props.placeholder}
         placeholder={props.placeholder}
         name="input"
         name="input"
+        data-testid="closable-text-input"
         onFocus={onFocusHandler}
         onFocus={onFocusHandler}
         onChange={onChangeHandler}
         onChange={onChangeHandler}
         onKeyDown={onKeyDownHandler}
         onKeyDown={onKeyDownHandler}
+        onCompositionStart={() => setComposing(true)}
+        onCompositionEnd={() => setComposing(false)}
         onBlur={onBlurHandler}
         onBlur={onBlurHandler}
         autoFocus={false}
         autoFocus={false}
       />
       />

+ 2 - 2
packages/app/src/components/IdenticalPathPage.tsx

@@ -3,7 +3,7 @@ import React, { FC } from 'react';
 import { DevidedPagePath } from '@growi/core';
 import { DevidedPagePath } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
-import { useCurrentPagePath, useIsSharedUser } from '~/stores/context';
+import { useCurrentPathname, useIsSharedUser } from '~/stores/context';
 import { useDescendantsPageListModal } from '~/stores/modal';
 import { useDescendantsPageListModal } from '~/stores/modal';
 import { useSWRxPageInfoForList, useSWRxPagesByPath } from '~/stores/page-listing';
 import { useSWRxPageInfoForList, useSWRxPagesByPath } from '~/stores/page-listing';
 
 
@@ -52,7 +52,7 @@ const IdenticalPathAlert : FC<IdenticalPathAlertProps> = (props: IdenticalPathAl
 export const IdenticalPathPage = (): JSX.Element => {
 export const IdenticalPathPage = (): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
-  const { data: currentPath } = useCurrentPagePath();
+  const { data: currentPath } = useCurrentPathname();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: isSharedUser } = useIsSharedUser();
 
 
   const { data: pages } = useSWRxPagesByPath(currentPath);
   const { data: pages } = useSWRxPagesByPath(currentPath);

+ 12 - 18
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -43,6 +43,16 @@ import { SubNavButtonsProps } from './SubNavButtons';
 import PageEditorModeManagerStyles from './PageEditorModeManager.module.scss';
 import PageEditorModeManagerStyles from './PageEditorModeManager.module.scss';
 
 
 
 
+const PageEditorModeManager = dynamic(
+  () => import('./PageEditorModeManager'),
+  { ssr: false, loading: () => <Skelton additionalClass={`${PageEditorModeManagerStyles['grw-page-editor-mode-manager-skelton']}`} /> },
+);
+const SubNavButtons = dynamic<SubNavButtonsProps>(
+  () => import('./SubNavButtons').then(mod => mod.SubNavButtons),
+  { ssr: false, loading: () => <Skelton additionalClass='btn-skelton py-2' /> },
+);
+
+
 type AdditionalMenuItemsProps = {
 type AdditionalMenuItemsProps = {
   pageId: string,
   pageId: string,
   revisionId: string,
   revisionId: string,
@@ -156,15 +166,6 @@ type GrowiContextualSubNavigationProps = {
 
 
 const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps): JSX.Element => {
 const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps): JSX.Element => {
 
 
-  const PageEditorModeManager = dynamic(
-    () => import('./PageEditorModeManager'),
-    { ssr: false, loading: () => <Skelton additionalClass={`${PageEditorModeManagerStyles['grw-page-editor-mode-manager-skelton']}`} /> },
-  );
-  const SubNavButtons = dynamic<SubNavButtonsProps>(
-    () => import('./SubNavButtons').then(mod => mod.SubNavButtons),
-    { ssr: false, loading: () => <Skelton additionalClass='btn-skelton py-2' /> },
-  );
-
   const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
   const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
   const path = currentPage?.path;
   const path = currentPage?.path;
 
 
@@ -353,15 +354,8 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
         )}
         )}
       </>
       </>
     );
     );
-  }, [
-    currentPage, currentUser, pageId, revisionId, shareLinkId, path, editorMode,
-    isCompactMode, isViewMode, isSharedUser, isAbleToShowPageManagement, isAbleToShowPageEditorModeManager,
-    isLinkSharingDisabled, isGuestUser, isPageTemplateModalShown,
-    duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler,
-    PageEditorModeManager, SubNavButtons,
-    mutateEditorMode,
-    templateMenuItemClickHandler,
-  ]);
+  // eslint-disable-next-line max-len
+  }, [currentPage, currentUser, pageId, revisionId, shareLinkId, path, editorMode, isCompactMode, isViewMode, isSharedUser, isAbleToShowPageManagement, isAbleToShowPageEditorModeManager, isLinkSharingDisabled, isGuestUser, isPageTemplateModalShown, duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler, mutateEditorMode, templateMenuItemClickHandler]);
 
 
   if (currentPathname == null) {
   if (currentPathname == null) {
     return <></>;
     return <></>;

+ 5 - 3
packages/app/src/components/PageAlert/TrashPageAlert.tsx

@@ -34,13 +34,15 @@ export const TrashPageAlert = (): JSX.Element => {
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openPutBackPageModal } = usePutBackPageModal();
   const { open: openPutBackPageModal } = usePutBackPageModal();
 
 
+  if (!isTrashPage) {
+    return <></>;
+  }
+
+
   const lastUpdateUserName = pageData?.lastUpdateUser?.name;
   const lastUpdateUserName = pageData?.lastUpdateUser?.name;
   const deletedAt = pageData?.deletedAt ? format(new Date(pageData?.deletedAt), 'yyyy/MM/dd HH:mm') : '';
   const deletedAt = pageData?.deletedAt ? format(new Date(pageData?.deletedAt), 'yyyy/MM/dd HH:mm') : '';
   const revisionId = pageData?.revision?._id;
   const revisionId = pageData?.revision?._id;
 
 
-  if (!isTrashPage) {
-    return <></>;
-  }
 
 
   function openPutbackPageModalHandler() {
   function openPutbackPageModalHandler() {
     if (pageId === undefined || pagePath === undefined) {
     if (pageId === undefined || pagePath === undefined) {

+ 31 - 14
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -1,5 +1,5 @@
 import React, {
 import React, {
-  useCallback, useState, FC, useEffect,
+  useCallback, useState, FC, useEffect, ReactNode,
 } from 'react';
 } from 'react';
 
 
 import nodePath from 'path';
 import nodePath from 'path';
@@ -94,6 +94,15 @@ const isDroppable = (fromPage?: Partial<IPageHasId>, newParentPage?: Partial<IPa
   return pagePathUtils.canMoveByPath(fromPage.path, newPathAfterMoved) && !pagePathUtils.isUsersTopPage(newParentPage.path);
   return pagePathUtils.canMoveByPath(fromPage.path, newPathAfterMoved) && !pagePathUtils.isUsersTopPage(newParentPage.path);
 };
 };
 
 
+// Component wrapper to make a child element not draggable
+// https://github.com/react-dnd/react-dnd/issues/335
+type NotDraggableProps = {
+  children: ReactNode,
+};
+const NotDraggableForClosableTextInput = (props: NotDraggableProps): JSX.Element => {
+  return <div draggable onDragStart={e => e.preventDefault()}>{props.children}</div>;
+};
+
 
 
 const Item: FC<ItemProps> = (props: ItemProps) => {
 const Item: FC<ItemProps> = (props: ItemProps) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -440,13 +449,17 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
         </div>
         </div>
         { isRenameInputShown
         { isRenameInputShown
           ? (
           ? (
-            <ClosableTextInput
-              value={nodePath.basename(page.path ?? '')}
-              placeholder={t('Input page name')}
-              onClickOutside={() => { setRenameInputShown(false) }}
-              onPressEnter={onPressEnterForRenameHandler}
-              inputValidator={inputValidator}
-            />
+            <div className="flex-fill">
+              <NotDraggableForClosableTextInput>
+                <ClosableTextInput
+                  value={nodePath.basename(page.path ?? '')}
+                  placeholder={t('Input page name')}
+                  onClickOutside={() => { setRenameInputShown(false) }}
+                  onPressEnter={onPressEnterForRenameHandler}
+                  inputValidator={inputValidator}
+                />
+              </NotDraggableForClosableTextInput>
+            </div>
           )
           )
           : (
           : (
             <>
             <>
@@ -502,12 +515,16 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       </li>
       </li>
 
 
       {isEnableActions && isNewPageInputShown && (
       {isEnableActions && isNewPageInputShown && (
-        <ClosableTextInput
-          placeholder={t('Input page name')}
-          onClickOutside={() => { setNewPageInputShown(false) }}
-          onPressEnter={onPressEnterForCreateHandler}
-          inputValidator={inputValidator}
-        />
+        <div className="flex-fill">
+          <NotDraggableForClosableTextInput>
+            <ClosableTextInput
+              placeholder={t('Input page name')}
+              onClickOutside={() => { setNewPageInputShown(false) }}
+              onPressEnter={onPressEnterForCreateHandler}
+              inputValidator={inputValidator}
+            />
+          </NotDraggableForClosableTextInput>
+        </div>
       )}
       )}
       {
       {
         isOpen && hasChildren() && currentChildren.map((node, index) => (
         isOpen && hasChildren() && currentChildren.map((node, index) => (

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

@@ -34,7 +34,7 @@ import { IUserUISettings } from '~/interfaces/user-ui-settings';
 import { PageModel, PageDocument } from '~/server/models/page';
 import { PageModel, PageDocument } from '~/server/models/page';
 import { PageRedirectModel } from '~/server/models/page-redirect';
 import { PageRedirectModel } from '~/server/models/page-redirect';
 import { UserUISettingsModel } from '~/server/models/user-ui-settings';
 import { UserUISettingsModel } from '~/server/models/user-ui-settings';
-import { useSWRxCurrentPage, useSWRxIsGrantNormalized, useSWRxPageInfo } from '~/stores/page';
+import { useSWRxCurrentPage, useSWRxIsGrantNormalized } from '~/stores/page';
 import { useRedirectFrom } from '~/stores/page-redirect';
 import { useRedirectFrom } from '~/stores/page-redirect';
 import {
 import {
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth, useSelectedGrant,
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth, useSelectedGrant,
@@ -242,7 +242,6 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
 
 
   useCurrentPageId(pageId);
   useCurrentPageId(pageId);
   useSWRxCurrentPage(undefined, pageWithMeta?.data); // store initial data
   useSWRxCurrentPage(undefined, pageWithMeta?.data); // store initial data
-  useSWRxPageInfo(pageId, undefined, pageWithMeta?.meta); // store initial data
   useIsTrashPage(_isTrashPage(pagePath));
   useIsTrashPage(_isTrashPage(pagePath));
   useIsUserPage(isUserPage(pagePath));
   useIsUserPage(isUserPage(pagePath));
   useIsNotCreatable(props.isForbidden || !isCreatablePage(pagePath)); // TODO: need to include props.isIdentical
   useIsNotCreatable(props.isForbidden || !isCreatablePage(pagePath)); // TODO: need to include props.isIdentical

+ 1 - 1
packages/app/src/server/routes/index.js

@@ -88,7 +88,7 @@ module.exports = function(crowi, app) {
   app.get('/register'                 , applicationInstalled, login.preLogin, login.register);
   app.get('/register'                 , applicationInstalled, login.preLogin, login.register);
 
 
   app.get('/admin/*'                    , applicationInstalled, loginRequiredStrictly , adminRequired , next.delegateToNext);
   app.get('/admin/*'                    , applicationInstalled, loginRequiredStrictly , adminRequired , next.delegateToNext);
-  // app.get('/admin'                    , applicationInstalled, loginRequiredStrictly , adminRequired , admin.index);
+  app.get('/admin'                    , applicationInstalled, loginRequiredStrictly , adminRequired , next.delegateToNext);
   // app.get('/admin/app'                , applicationInstalled, loginRequiredStrictly , adminRequired , admin.app.index);
   // app.get('/admin/app'                , applicationInstalled, loginRequiredStrictly , adminRequired , admin.app.index);
 
 
   // installer
   // installer

+ 21 - 14
packages/app/src/server/service/global-notification/global-notification-mail.js

@@ -20,17 +20,17 @@ class GlobalNotificationMailService {
    * @memberof GlobalNotificationMailService
    * @memberof GlobalNotificationMailService
    *
    *
    * @param {string} event event name triggered
    * @param {string} event event name triggered
-   * @param {string} path path triggered the event
+   * @param {import('~/server/models/page').PageDocument} page page triggered the event
    * @param {User} triggeredBy user who triggered the event
    * @param {User} triggeredBy user who triggered the event
    * @param {{ comment: Comment, oldPath: string }} _ event specific vars
    * @param {{ comment: Comment, oldPath: string }} _ event specific vars
    */
    */
-  async fire(event, path, triggeredBy, vars) {
+  async fire(event, page, triggeredBy, vars) {
     const { mailService } = this.crowi;
     const { mailService } = this.crowi;
 
 
     const GlobalNotification = this.crowi.model('GlobalNotificationSetting');
     const GlobalNotification = this.crowi.model('GlobalNotificationSetting');
-    const notifications = await GlobalNotification.findSettingByPathAndEvent(event, path, this.type);
+    const notifications = await GlobalNotification.findSettingByPathAndEvent(event, page.path, this.type);
 
 
-    const option = this.generateOption(event, path, triggeredBy, vars);
+    const option = this.generateOption(event, page, triggeredBy, vars);
 
 
     await Promise.all(notifications.map((notification) => {
     await Promise.all(notifications.map((notification) => {
       return mailService.send({ ...option, to: notification.toEmail });
       return mailService.send({ ...option, to: notification.toEmail });
@@ -43,38 +43,45 @@ class GlobalNotificationMailService {
    * @memberof GlobalNotificationMailService
    * @memberof GlobalNotificationMailService
    *
    *
    * @param {string} event event name triggered
    * @param {string} event event name triggered
-   * @param {string} path path triggered the event
+   * @param {import('~/server/models/page').PageDocument} page path triggered the event
    * @param {User} triggeredBy user triggered the event
    * @param {User} triggeredBy user triggered the event
    * @param {{ comment: Comment, oldPath: string }} _ event specific vars
    * @param {{ comment: Comment, oldPath: string }} _ event specific vars
    *
    *
    * @return  {{ subject: string, template: string, vars: object }}
    * @return  {{ subject: string, template: string, vars: object }}
    */
    */
-  generateOption(event, path, triggeredBy, { comment, oldPath }) {
+  generateOption(event, page, triggeredBy, { comment, oldPath }) {
     const defaultLang = this.crowi.configManager.getConfig('crowi', 'app:globalLang');
     const defaultLang = this.crowi.configManager.getConfig('crowi', 'app:globalLang');
     // validate for all events
     // validate for all events
-    if (event == null || path == null || triggeredBy == null) {
+    if (event == null || page == null || triggeredBy == null) {
       throw new Error(`invalid vars supplied to GlobalNotificationMailService.generateOption for event ${event}`);
       throw new Error(`invalid vars supplied to GlobalNotificationMailService.generateOption for event ${event}`);
     }
     }
 
 
     const template = nodePath.join(this.crowi.localeDir, `${defaultLang}/notifications/${event}.txt`);
     const template = nodePath.join(this.crowi.localeDir, `${defaultLang}/notifications/${event}.txt`);
+
+    const path = page.path;
+    const appTitle = this.crowi.appService.getAppTitle();
+    const siteUrl = this.crowi.appService.getSiteUrl();
+    const pageUrl = new URL(page._id, siteUrl);
+
     let subject;
     let subject;
     let vars = {
     let vars = {
-      appTitle: this.crowi.appService.getAppTitle(),
+      appTitle,
+      siteUrl,
       path,
       path,
       username: triggeredBy.username,
       username: triggeredBy.username,
     };
     };
 
 
     switch (event) {
     switch (event) {
       case this.event.PAGE_CREATE:
       case this.event.PAGE_CREATE:
-        subject = `#${event} - ${triggeredBy.username} created ${path}`;
+        subject = `#${event} - ${triggeredBy.username} created ${path} at URL: ${pageUrl}`;
         break;
         break;
 
 
       case this.event.PAGE_EDIT:
       case this.event.PAGE_EDIT:
-        subject = `#${event} - ${triggeredBy.username} edited ${path}`;
+        subject = `#${event} - ${triggeredBy.username} edited ${path} at URL: ${pageUrl}`;
         break;
         break;
 
 
       case this.event.PAGE_DELETE:
       case this.event.PAGE_DELETE:
-        subject = `#${event} - ${triggeredBy.username} deleted ${path}`;
+        subject = `#${event} - ${triggeredBy.username} deleted ${path} at URL: ${pageUrl}`;
         break;
         break;
 
 
       case this.event.PAGE_MOVE:
       case this.event.PAGE_MOVE:
@@ -83,7 +90,7 @@ class GlobalNotificationMailService {
           throw new Error(`invalid vars supplied to GlobalNotificationMailService.generateOption for event ${event}`);
           throw new Error(`invalid vars supplied to GlobalNotificationMailService.generateOption for event ${event}`);
         }
         }
 
 
-        subject = `#${event} - ${triggeredBy.username} moved ${oldPath} to ${path}`;
+        subject = `#${event} - ${triggeredBy.username} moved ${oldPath} to ${path} at URL: ${pageUrl}`;
         vars = {
         vars = {
           ...vars,
           ...vars,
           oldPath,
           oldPath,
@@ -92,7 +99,7 @@ class GlobalNotificationMailService {
         break;
         break;
 
 
       case this.event.PAGE_LIKE:
       case this.event.PAGE_LIKE:
-        subject = `#${event} - ${triggeredBy.username} liked ${path}`;
+        subject = `#${event} - ${triggeredBy.username} liked ${path} at URL: ${pageUrl}`;
         break;
         break;
 
 
       case this.event.COMMENT:
       case this.event.COMMENT:
@@ -101,7 +108,7 @@ class GlobalNotificationMailService {
           throw new Error(`invalid vars supplied to GlobalNotificationMailService.generateOption for event ${event}`);
           throw new Error(`invalid vars supplied to GlobalNotificationMailService.generateOption for event ${event}`);
         }
         }
 
 
-        subject = `#${event} - ${triggeredBy.username} commented on ${path}`;
+        subject = `#${event} - ${triggeredBy.username} commented on ${path} at URL: ${pageUrl}`;
         vars = {
         vars = {
           ...vars,
           ...vars,
           comment: comment.comment,
           comment: comment.comment,

+ 1 - 1
packages/app/src/server/service/global-notification/index.js

@@ -45,7 +45,7 @@ class GlobalNotificationService {
     }
     }
 
 
     await Promise.all([
     await Promise.all([
-      this.gloabalNotificationMail.fire(event, page.path, triggeredBy, vars),
+      this.gloabalNotificationMail.fire(event, page, triggeredBy, vars),
       this.gloabalNotificationSlack.fire(event, page.id, page.path, triggeredBy, vars),
       this.gloabalNotificationSlack.fire(event, page.id, page.path, triggeredBy, vars),
     ]);
     ]);
   }
   }

+ 4 - 1
packages/app/src/styles/style-next.scss

@@ -17,9 +17,12 @@
 @import '~katex/dist/katex.min';
 @import '~katex/dist/katex.min';
 
 
 // icons
 // icons
+
+// DO NOT CHANGE THER OERDER OF font-awesome AND simple-line-icons.
+// font-familiy used in simple-line-icons has to be prioritized than the one used in font-awesome.
+@import '~font-awesome';
 @import '~simple-line-icons';
 @import '~simple-line-icons';
 @import '~material-icons/iconfont/filled';
 @import '~material-icons/iconfont/filled';
-@import '~font-awesome';
 @import '~@icon/themify-icons/themify-icons';
 @import '~@icon/themify-icons/themify-icons';
 
 
 // atoms
 // atoms

+ 1 - 1
packages/app/test/cypress/integration/50-sidebar/access-to-side-bar.spec.ts

@@ -102,7 +102,7 @@ context('Access to sidebar', () => {
     });
     });
 
 
     cy.get('.grw-pagetree-item-children').eq(0).within(() => {
     cy.get('.grw-pagetree-item-children').eq(0).within(() => {
-      cy.get('.flex-fill > input').type('_newname');
+      cy.getByTestid('closable-text-input').type('_newname');
     });
     });
 
 
     cy.getByTestid('grw-contextual-navigation-sub').screenshot(`${ssPrefix}page-tree-6-rename-page`);
     cy.getByTestid('grw-contextual-navigation-sub').screenshot(`${ssPrefix}page-tree-6-rename-page`);

+ 0 - 109
packages/plugin-lsx/src/components/LsxPageList/LsxPage.jsx

@@ -1,109 +0,0 @@
-import React from 'react';
-
-import { pathUtils } from '@growi/core';
-import { PageListMeta } from '@growi/ui';
-import PropTypes from 'prop-types';
-
-import { PageNode } from '../PageNode';
-import { LsxContext } from '../lsx-context';
-
-import { PagePathWrapper } from './PagePathWrapper';
-
-export class LsxPage extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isExists: false,
-      isLinkable: false,
-      hasChildren: false,
-    };
-  }
-
-  UNSAFE_componentWillMount() {
-    const pageNode = this.props.pageNode;
-
-    if (pageNode.page !== undefined) {
-      this.setState({ isExists: true });
-    }
-    if (pageNode.children.length > 0) {
-      this.setState({ hasChildren: true });
-    }
-
-    // process depth option
-    const optDepth = this.props.lsxContext.getOptDepth();
-    if (optDepth == null) {
-      this.setState({ isLinkable: true });
-    }
-    else {
-      const depth = this.props.depth;
-
-      // debug
-      // console.log(pageNode.pagePath, {depth, decGens, 'optDepth.start': optDepth.start, 'optDepth.end': optDepth.end});
-
-      const isLinkable = optDepth.start <= depth;
-      this.setState({ isLinkable });
-    }
-  }
-
-  getChildPageElement() {
-    const pageNode = this.props.pageNode;
-
-    let element = '';
-
-    // create child pages elements
-    if (this.state.hasChildren) {
-      const pages = pageNode.children.map((pageNode) => {
-        return (
-          <LsxPage
-            key={pageNode.pagePath}
-            depth={this.props.depth + 1}
-            pageNode={pageNode}
-            lsxContext={this.props.lsxContext}
-            basisViewersCount={this.props.basisViewersCount}
-          />
-        );
-      });
-
-      element = <ul className="page-list-ul">{pages}</ul>;
-    }
-
-    return element;
-  }
-
-  getIconElement() {
-    return (this.state.isExists)
-      ? <i className="ti ti-agenda" aria-hidden="true"></i>
-      : <i className="ti ti-file lsx-page-not-exist" aria-hidden="true"></i>;
-  }
-
-  render() {
-    const { pageNode, basisViewersCount } = this.props;
-
-    // create PagePath element
-    let pagePathNode = <PagePathWrapper pagePath={pageNode.pagePath} isExists={this.state.isExists} />;
-    if (this.state.isLinkable) {
-      pagePathNode = <a className="page-list-link" href={encodeURI(pathUtils.removeTrailingSlash(pageNode.pagePath))}>{pagePathNode}</a>;
-    }
-
-    // create PageListMeta element
-    const pageListMeta = (this.state.isExists) ? <PageListMeta page={pageNode.page} basisViewersCount={basisViewersCount} /> : '';
-
-    return (
-      <li className="page-list-li">
-        <small>{this.getIconElement()}</small> {pagePathNode}
-        <span className="ml-2">{pageListMeta}</span>
-        {this.getChildPageElement()}
-      </li>
-    );
-  }
-
-}
-
-LsxPage.propTypes = {
-  pageNode: PropTypes.instanceOf(PageNode).isRequired,
-  lsxContext: PropTypes.instanceOf(LsxContext).isRequired,
-  depth: PropTypes.number,
-  basisViewersCount: PropTypes.number,
-};

+ 95 - 0
packages/plugin-lsx/src/components/LsxPageList/LsxPage.tsx

@@ -0,0 +1,95 @@
+import React, { useMemo } from 'react';
+
+import { pathUtils } from '@growi/core';
+import { PagePathLabel, PageListMeta } from '@growi/ui';
+
+import { PageNode } from '../PageNode';
+import { LsxContext } from '../lsx-context';
+
+
+type Props = {
+  pageNode: PageNode,
+  lsxContext: LsxContext,
+  depth: number,
+  basisViewersCount?: number,
+};
+
+export const LsxPage = React.memo((props: Props): JSX.Element => {
+  const {
+    pageNode, lsxContext, depth, basisViewersCount,
+  } = props;
+
+  const isExists = pageNode.page !== undefined;
+  const isLinkable = (() => {
+    // process depth option
+    const optDepth = lsxContext.getOptDepth();
+    if (optDepth == null) {
+      return true;
+    }
+
+    // debug
+    // console.log(pageNode.pagePath, {depth, decGens, 'optDepth.start': optDepth.start, 'optDepth.end': optDepth.end});
+
+    return optDepth.start <= depth;
+  })();
+  const hasChildren = pageNode.children.length > 0;
+
+  const childrenElements: JSX.Element = useMemo(() => {
+    let element = <></>;
+
+    // create child pages elements
+    if (hasChildren) {
+      const pages = pageNode.children.map((pageNode) => {
+        return (
+          <LsxPage
+            key={pageNode.pagePath}
+            depth={depth + 1}
+            pageNode={pageNode}
+            lsxContext={lsxContext}
+            basisViewersCount={basisViewersCount}
+          />
+        );
+      });
+
+      element = <ul className="page-list-ul">{pages}</ul>;
+    }
+
+    return element;
+  }, [basisViewersCount, depth, hasChildren, lsxContext, pageNode.children]);
+
+  const iconElement: JSX.Element = useMemo(() => {
+    return (isExists)
+      ? <i className="ti ti-agenda" aria-hidden="true"></i>
+      : <i className="ti ti-file lsx-page-not-exist" aria-hidden="true"></i>;
+  }, [isExists]);
+
+  const pagePathElement: JSX.Element = useMemo(() => {
+    const classNames: string[] = [];
+    if (!isExists) {
+      classNames.push('lsx-page-not-exist');
+    }
+
+    // create PagePath element
+    let pagePathNode = <PagePathLabel path={pageNode.pagePath} isLatterOnly additionalClassNames={classNames} />;
+    if (isLinkable) {
+      pagePathNode = <a className="page-list-link" href={encodeURI(pathUtils.removeTrailingSlash(pageNode.pagePath))}>{pagePathNode}</a>;
+    }
+    return pagePathNode;
+  }, [isExists, isLinkable, pageNode.pagePath]);
+
+  const pageListMetaElement: JSX.Element = useMemo(() => {
+    if (!isExists) {
+      return <></>;
+    }
+    return <PageListMeta page={pageNode.page} basisViewersCount={basisViewersCount} />;
+  }, [basisViewersCount, isExists, pageNode.page]);
+
+  return (
+    <li className="page-list-li">
+      <small>{iconElement}</small> {pagePathElement}
+      <span className="ml-2">{pageListMetaElement}</span>
+      {childrenElements}
+    </li>
+  );
+
+});

+ 0 - 31
packages/plugin-lsx/src/components/LsxPageList/PagePathWrapper.jsx

@@ -1,31 +0,0 @@
-import React from 'react';
-
-import { PagePathLabel } from '@growi/ui';
-import PropTypes from 'prop-types';
-
-
-export class PagePathWrapper extends React.Component {
-
-  render() {
-
-    const classNames = [];
-    if (!this.props.isExists) {
-      classNames.push('lsx-page-not-exist');
-    }
-
-    return (
-      <PagePathLabel path={this.props.pagePath} isLatterOnly additionalClassNames={classNames} />
-    );
-  }
-
-}
-
-PagePathWrapper.propTypes = {
-  pagePath: PropTypes.string.isRequired,
-  isExists: PropTypes.bool.isRequired,
-  excludePathString: PropTypes.string,
-};
-
-PagePathWrapper.defaultProps = {
-  excludePathString: '',
-};