Przeglądaj źródła

Merge branch 'dev/5.0.x' into fix/84319-84335-fix-page-tree-plus-icon

* dev/5.0.x: (48 commits)
  remove unused state
  change toastError position
  fix try catch syntax
  improve fetch method
  use swr data
  improve class
  using useTranslation instead of withTranslation
  replaced class component to functional one
  fix grammar
  clean code
  fix alert message
  add z-index
  remove unnecessary styles
  set preventOverflow to modifiers of DropdownMenu
  change to reactstrap
  Improved alert message
  Added a comment
  Improved regex
  escape tags
  Mode style
  ...
Mao 4 lat temu
rodzic
commit
b7a58f4fab

+ 3 - 1
packages/app/resource/locales/en_US/translation.json

@@ -39,6 +39,7 @@
   "account_id": "Account Id",
   "Update": "Update",
   "Update Page": "Update Page",
+  "Error": "Error",
   "Warning": "Warning",
   "Sign in": "Sign in",
   "Sign up is here": "Sign up",
@@ -168,7 +169,8 @@
   "form_validation": {
     "error_message": "Some values ​​are incorrect",
     "required": "%s is required",
-    "invalid_syntax": "The syntax of %s is invalid."
+    "invalid_syntax": "The syntax of %s is invalid.",
+    "title_required": "Title is required."
   },
   "not_found_page": {
     "Create Page": "Create Page",

+ 3 - 1
packages/app/resource/locales/ja_JP/translation.json

@@ -40,6 +40,7 @@
   "Initialize": "初期化",
   "Update": "更新",
   "Update Page": "ページを更新",
+  "Error": "エラー",
   "Warning": "注意",
   "Sign in": "ログイン",
   "Sign up is here": "新規登録はこちら",
@@ -170,7 +171,8 @@
   "form_validation": {
     "error_message": "いくつかの値が設定されていません",
     "required": "%sに値を入力してください",
-    "invalid_syntax": "%sの構文が不正です"
+    "invalid_syntax": "%sの構文が不正です",
+    "title_required": "タイトルを入力してください"
   },
   "not_found_page": {
     "Create Page": "ページを作成する",

+ 3 - 1
packages/app/resource/locales/zh_CN/translation.json

@@ -41,6 +41,7 @@
 	"Initialize": "初始化",
   "Update": "更新",
 	"Update Page": "更新本页",
+	"Error": "误差",
 	"Warning": "警告",
   "Sign in": "登录",
 	"Sign up is here": "注册",
@@ -168,7 +169,8 @@
 	"form_validation": {
 		"error_message": "有些值不正确",
 		"required": "%s 是必需的",
-		"invalid_syntax": "%s的语法无效。"
+		"invalid_syntax": "%s的语法无效。",
+    "title_required": "标题是必需的。"
   },
   "not_found_page": {
     "Create Page": "创建页面",

+ 1 - 2
packages/app/src/client/services/AdminHomeContainer.js

@@ -25,7 +25,6 @@ export default class AdminHomeContainer extends Container {
     this.timer = null;
 
     this.state = {
-      retrieveError: null,
       growiVersion: '',
       nodeVersion: '',
       npmVersion: '',
@@ -69,7 +68,7 @@ export default class AdminHomeContainer extends Container {
     }
     catch (err) {
       logger.error(err);
-      toastError(new Error('Failed to fetch data'));
+      throw new Error('Failed to retrive AdminHome data');
     }
   }
 

+ 81 - 86
packages/app/src/components/Admin/AdminHome/AdminHome.jsx

@@ -1,6 +1,6 @@
-import React, { Fragment } from 'react';
+import React, { useEffect, useCallback } from 'react';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
 import { Tooltip } from 'reactstrap';
 import loggerFactory from '~/utils/logger';
@@ -10,117 +10,112 @@ import { toastError } from '~/client/util/apiNotification';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AdminHomeContainer from '~/client/services/AdminHomeContainer';
-import AdminAppContainer from '~/client/services/AdminAppContainer';
+import { useSWRxV5MigrationStatus } from '~/stores/page-listing';
 import SystemInfomationTable from './SystemInfomationTable';
 import InstalledPluginTable from './InstalledPluginTable';
 import EnvVarsTable from './EnvVarsTable';
 
 const logger = loggerFactory('growi:admin');
 
-class AdminHome extends React.Component {
-
-  async componentDidMount() {
-    const { adminHomeContainer } = this.props;
+const AdminHome = (props) => {
+  const { adminHomeContainer } = props;
+  const { t } = useTranslation();
+  const { data: migrationStatus } = useSWRxV5MigrationStatus();
 
+  const fetchAdminHomeData = useCallback(async() => {
     try {
       await adminHomeContainer.retrieveAdminHomeData();
     }
     catch (err) {
       toastError(err);
-      adminHomeContainer.setState({ retrieveError: err });
       logger.error(err);
     }
-  }
-
-  render() {
-    const { t, adminHomeContainer } = this.props;
-    const { isV5Compatible } = adminHomeContainer.state;
-
-    let alertStyle = 'alert-info';
-    if (isV5Compatible == null) alertStyle = 'alert-warning';
-
-    return (
-      <Fragment>
-        {
-          // not show if true
-          !isV5Compatible
-          && (
-            <div className={`alert ${alertStyle}`}>
-              {t('admin:v5_page_migration.migration_desc')}
-              <a className="btn-link" href="/admin/app" rel="noopener noreferrer">
-                <i className="fa fa-link ml-1" aria-hidden="true"></i>
-                <strong>{t('admin:v5_page_migration.upgrade_to_v5')}</strong>
-              </a>
-            </div>
-          )
-        }
-        <p>
-          {t('admin:admin_top.wiki_administrator')}
-          <br></br>
-          {t('admin:admin_top.assign_administrator')}
-        </p>
-
-        <div className="row mb-5">
-          <div className="col-lg-12">
-            <h2 className="admin-setting-header">{t('admin:admin_top.system_information')}</h2>
-            <SystemInfomationTable />
+  }, [adminHomeContainer]);
+
+  useEffect(() => {
+    fetchAdminHomeData();
+  }, [fetchAdminHomeData]);
+
+  return (
+    <>
+      {
+      // Alert message will be displayed in case that V5 migration has not been compleated
+        (migrationStatus != null && !migrationStatus.isV5Compatible)
+        && (
+          <div className={`alert ${migrationStatus.isV5Compatible == null ? 'alert-warning' : 'alert-info'}`}>
+            {t('admin:v5_page_migration.migration_desc')}
+            <a className="btn-link" href="/admin/app" rel="noopener noreferrer">
+              <i className="fa fa-link ml-1" aria-hidden="true"></i>
+              <strong>{t('admin:v5_page_migration.upgrade_to_v5')}</strong>
+            </a>
           </div>
+        )
+      }
+      <p>
+        {t('admin:admin_top.wiki_administrator')}
+        <br></br>
+        {t('admin:admin_top.assign_administrator')}
+      </p>
+
+      <div className="row mb-5">
+        <div className="col-lg-12">
+          <h2 className="admin-setting-header">{t('admin:admin_top.system_information')}</h2>
+          <SystemInfomationTable />
         </div>
+      </div>
 
-        <div className="row mb-5">
-          <div className="col-lg-12">
-            <h2 className="admin-setting-header">{t('admin:admin_top.list_of_installed_plugins')}</h2>
-            <InstalledPluginTable />
-          </div>
+      <div className="row mb-5">
+        <div className="col-lg-12">
+          <h2 className="admin-setting-header">{t('admin:admin_top.list_of_installed_plugins')}</h2>
+          <InstalledPluginTable />
         </div>
-
-        <div className="row mb-5">
-          <div className="col-md-12">
-            <h2 className="admin-setting-header">{t('admin:admin_top.list_of_env_vars')}</h2>
-            <p>{t('admin:admin_top.env_var_priority')}</p>
-            {/* eslint-disable-next-line react/no-danger */}
-            <p dangerouslySetInnerHTML={{ __html: t('admin:admin_top.about_security') }} />
-            {adminHomeContainer.state.envVars && <EnvVarsTable envVars={adminHomeContainer.state.envVars} />}
-          </div>
+      </div>
+
+      <div className="row mb-5">
+        <div className="col-md-12">
+          <h2 className="admin-setting-header">{t('admin:admin_top.list_of_env_vars')}</h2>
+          <p>{t('admin:admin_top.env_var_priority')}</p>
+          {/* eslint-disable-next-line react/no-danger */}
+          <p dangerouslySetInnerHTML={{ __html: t('admin:admin_top.about_security') }} />
+          {adminHomeContainer.state.envVars && <EnvVarsTable envVars={adminHomeContainer.state.envVars} />}
         </div>
-
-        <div className="row mb-5">
-          <div className="col-md-12">
-            <h2 className="admin-setting-header">{t('admin:admin_top.bug_report')}</h2>
-            <div className="d-flex align-items-center">
-              <CopyToClipboard
-                text={adminHomeContainer.generatePrefilledHostInformationMarkdown()}
-                onCopy={() => adminHomeContainer.onCopyPrefilledHostInformation()}
-              >
-                <button id="prefilledHostInformationButton" type="button" className="btn btn-primary">
-                  {t('admin:admin_top:copy_prefilled_host_information:default')}
-                </button>
-              </CopyToClipboard>
-              <Tooltip
-                placement="bottom"
-                isOpen={adminHomeContainer.state.copyState === adminHomeContainer.copyStateValues.DONE}
-                target="prefilledHostInformationButton"
-                fade={false}
-              >
-                {t('admin:admin_top:copy_prefilled_host_information:done')}
-              </Tooltip>
-              {/* eslint-disable-next-line react/no-danger */}
-              <span className="ml-2" dangerouslySetInnerHTML={{ __html: t('admin:admin_top:submit_bug_report') }} />
-            </div>
+      </div>
+
+      <div className="row mb-5">
+        <div className="col-md-12">
+          <h2 className="admin-setting-header">{t('admin:admin_top.bug_report')}</h2>
+          <div className="d-flex align-items-center">
+            <CopyToClipboard
+              text={adminHomeContainer.generatePrefilledHostInformationMarkdown()}
+              onCopy={() => adminHomeContainer.onCopyPrefilledHostInformation()}
+            >
+              <button id="prefilledHostInformationButton" type="button" className="btn btn-primary">
+                {t('admin:admin_top:copy_prefilled_host_information:default')}
+              </button>
+            </CopyToClipboard>
+            <Tooltip
+              placement="bottom"
+              isOpen={adminHomeContainer.state.copyState === adminHomeContainer.copyStateValues.DONE}
+              target="prefilledHostInformationButton"
+              fade={false}
+            >
+              {t('admin:admin_top:copy_prefilled_host_information:done')}
+            </Tooltip>
+            {/* eslint-disable-next-line react/no-danger */}
+            <span className="ml-2" dangerouslySetInnerHTML={{ __html: t('admin:admin_top:submit_bug_report') }} />
           </div>
         </div>
-      </Fragment>
-    );
-  }
+      </div>
+    </>
+  );
+};
 
-}
 
 const AdminHomeWrapper = withUnstatedContainers(AdminHome, [AppContainer, AdminHomeContainer]);
 
 AdminHome.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   adminHomeContainer: PropTypes.instanceOf(AdminHomeContainer).isRequired,
 };
 
-export default withTranslation()(AdminHomeWrapper);
+export default AdminHomeWrapper;

+ 5 - 1
packages/app/src/components/Common/ClosableTextInput.tsx

@@ -1,6 +1,7 @@
 import React, {
   FC, memo, useEffect, useRef, useState,
 } from 'react';
+import { useTranslation } from 'react-i18next';
 
 export const AlertType = {
   WARNING: 'warning',
@@ -23,6 +24,7 @@ type ClosableTextInputProps = {
 }
 
 const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextInputProps) => {
+  const { t } = useTranslation();
   const inputRef = useRef<HTMLInputElement>(null);
 
   const [currentAlertInfo, setAlertInfo] = useState<AlertInfo | null>(null);
@@ -81,8 +83,10 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
 
     const alertType = currentAlertInfo.type != null ? currentAlertInfo.type : AlertType.ERROR;
     const alertMessage = currentAlertInfo.message != null ? currentAlertInfo.message : 'Invalid value';
+    const alertTextStyle = alertType === AlertType.ERROR ? 'text-danger' : 'text-warning';
+    const translation = alertType === AlertType.ERROR ? 'Error' : 'Warning';
     return (
-      <p className="text-danger text-center mt-1">{alertType}: {alertMessage}</p>
+      <p className={`${alertTextStyle} text-center mt-1`}>{t(translation)}: {alertMessage}</p>
     );
   };
 

+ 26 - 22
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -1,4 +1,7 @@
 import React, { FC } from 'react';
+import {
+  UncontrolledDropdown, DropdownMenu, DropdownToggle, DropdownItem,
+} from 'reactstrap';
 
 import toastr from 'toastr';
 import { useTranslation } from 'react-i18next';
@@ -25,15 +28,11 @@ const PageItemControl: FC<PageItemControlProps> = (props: PageItemControlProps)
     }
   };
   return (
-    <>
-      <button
-        type="button"
-        className="btn-link dropdown-toggle dropdown-toggle-no-caret border-0 rounded grw-btn-page-management py-0 px-2"
-        data-toggle="dropdown"
-      >
+    <UncontrolledDropdown>
+      <DropdownToggle color="transparent" className="btn-link border-0 rounded grw-btn-page-management py-0 px-2">
         <i className="fa fa-ellipsis-v text-muted p-1"></i>
-      </button>
-      <div className="dropdown-menu dropdown-menu-right">
+      </DropdownToggle>
+      <DropdownMenu positionFixed modifiers={{ preventOverflow: { boundariesElement: undefined } }}>
 
         {/* TODO: if there is the following button in XD add it here
         <button
@@ -54,40 +53,45 @@ const PageItemControl: FC<PageItemControlProps> = (props: PageItemControlProps)
         */}
 
         {/* TODO: show dropdown when permalink section is implemented */}
+
         {!isEnableActions && (
-          <p className="dropdown-item">
-            {t('search_result.currently_not_implemented')}
-          </p>
+          <DropdownItem>
+            <p>
+              {t('search_result.currently_not_implemented')}
+            </p>
+          </DropdownItem>
         )}
         {isEnableActions && (
-          <button className="dropdown-item" type="button" onClick={() => toastr.warning(t('search_result.currently_not_implemented'))}>
+          <DropdownItem onClick={() => toastr.warning(t('search_result.currently_not_implemented'))}>
             <i className="icon-fw icon-star"></i>
             {t('Add to bookmark')}
-          </button>
+          </DropdownItem>
         )}
         {isEnableActions && (
-          <button className="dropdown-item" type="button" onClick={() => toastr.warning(t('search_result.currently_not_implemented'))}>
+          <DropdownItem onClick={() => toastr.warning(t('search_result.currently_not_implemented'))}>
             <i className="icon-fw icon-docs"></i>
             {t('Duplicate')}
-          </button>
+          </DropdownItem>
         )}
         {isEnableActions && (
-          <button className="dropdown-item" type="button" onClick={() => toastr.warning(t('search_result.currently_not_implemented'))}>
+          <DropdownItem onClick={() => toastr.warning(t('search_result.currently_not_implemented'))}>
             <i className="icon-fw  icon-action-redo"></i>
             {t('Move/Rename')}
-          </button>
+          </DropdownItem>
         )}
         {isDeletable && isEnableActions && (
           <>
-            <div className="dropdown-divider"></div>
-            <button className="dropdown-item text-danger pt-2" type="button" onClick={deleteButtonHandler}>
+            <DropdownItem divider />
+            <DropdownItem className="text-danger pt-2" onClick={deleteButtonHandler}>
               <i className="icon-fw icon-trash"></i>
               {t('Delete')}
-            </button>
+            </DropdownItem>
           </>
         )}
-      </div>
-    </>
+      </DropdownMenu>
+
+
+    </UncontrolledDropdown>
   );
 
 };

+ 1 - 1
packages/app/src/components/Page/CopyDropdown.jsx

@@ -118,7 +118,7 @@ const CopyDropdown = (props) => {
           <span id={dropdownToggleId}>{children}</span>
         </DropdownToggle>
 
-        <DropdownMenu positionFixed modifiers={{ preventOverflow: { boundariesElement: null } }}>
+        <DropdownMenu positionFixed modifiers={{ preventOverflow: { boundariesElement: undefined } }}>
 
           <div className="d-flex align-items-center justify-content-between">
             <DropdownItem header className="px-3">

+ 9 - 6
packages/app/src/components/Page/RevisionRenderer.jsx

@@ -60,20 +60,23 @@ class LegacyRevisionRenderer extends React.PureComponent {
    * @param {string} keywords
    */
   getHighlightedBody(body, keywords) {
-    let returnBody = body;
+    const returnBody = body;
 
-    keywords.replace(/"/g, '').split(' ').forEach((keyword) => {
+    const normalizedKeywordsArray = [];
+    keywords.replace(/"/g, '').split(/[\u{20}\u{3000}]/u).forEach((keyword, i) => { // split by both full-with and half-width space
       if (keyword === '') {
         return;
       }
       const k = keyword
-        .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+        .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // escape regex operators
         .replace(/(^"|"$)/g, ''); // for phrase (quoted) keyword
-      const keywordExp = new RegExp(`(${k}(?!(.*?")))`, 'ig');
-      returnBody = returnBody.replace(keywordExp, '<em class="highlighted-keyword">$&</em>');
+      normalizedKeywordsArray.push(k);
     });
 
-    return returnBody;
+    const normalizedKeywords = `(${normalizedKeywordsArray.join('|')})`;
+    const keywordExp = new RegExp(`(?<!<)${normalizedKeywords}(?!(.*?("|>)))`, 'ig'); // exclude html tag as well https://regex101.com/r/dznxyh/1
+
+    return returnBody.replace(keywordExp, '<em class="highlighted-keyword">$&</em>');
   }
 
   async renderHtml() {

+ 1 - 1
packages/app/src/components/RevisionComparer/RevisionComparer.jsx

@@ -74,7 +74,7 @@ const RevisionComparer = (props) => {
           >
             <i className="ti-clipboard"></i>
           </DropdownToggle>
-          <DropdownMenu positionFixed right modifiers={{ preventOverflow: { boundariesElement: null } }}>
+          <DropdownMenu positionFixed right modifiers={{ preventOverflow: { boundariesElement: undefined } }}>
             {/* Page path URL */}
             <CopyToClipboard text={pagePathUrl()}>
               <DropdownItem className="px-3">

+ 12 - 4
packages/app/src/components/SearchPage/DeleteSelectedPageGroup.tsx

@@ -1,5 +1,6 @@
-import React, { FC } from 'react';
+import React, { FC, useEffect, useRef } from 'react';
 import { useTranslation } from 'react-i18next';
+import { IndeterminateInputElement } from '~/interfaces/indeterminate-input-elm';
 import { CheckboxType } from '../../interfaces/search';
 
 type Props = {
@@ -26,18 +27,25 @@ const DeleteSelectedPageGroup:FC<Props> = (props:Props) => {
     if (onClickDeleteAllButton != null) { onClickDeleteAllButton() }
   };
 
+  const selectAllCheckboxElm = useRef<IndeterminateInputElement>(null);
+  useEffect(() => {
+    if (selectAllCheckboxElm.current != null) {
+      selectAllCheckboxElm.current.indeterminate = selectAllCheckboxType === CheckboxType.INDETERMINATE;
+    }
+  }, [selectAllCheckboxType]);
+
   return (
 
     <div className="d-flex align-items-center">
-      {/** todo: implement the design for CheckboxType = INDETERMINATE */}
       <input
         id="check-all-pages"
         type="checkbox"
         name="check-all-pages"
-        className="custom-control custom-checkbox align-self-center"
+        className="grw-indeterminate-checkbox"
+        ref={selectAllCheckboxElm}
         disabled={props.isSelectAllCheckboxDisabled}
         onClick={onClickCheckbox}
-        checked={selectAllCheckboxType !== CheckboxType.NONE_CHECKED}
+        checked={selectAllCheckboxType === CheckboxType.ALL_CHECKED}
       />
       <button
         type="button"

+ 1 - 1
packages/app/src/components/SearchPage/SearchResultContentSubNavigation.tsx

@@ -32,7 +32,7 @@ const SearchResultContentSubNavigation: FC<Props> = (props : Props) => {
   const { isSharedUser } = appContainer;
   const isAbleToShowPageManagement = !(isTrashPage(path)) && !isSharedUser;
   return (
-    <div className="position-sticky fixed-top shadow-sm search-result-content-nav">
+    <div className="shadow-sm search-result-content-nav">
       <div className={`grw-subnav container-fluid d-flex align-items-start justify-content-between ${isCompactMode ? 'grw-subnav-compact d-print-none' : ''}`}>
         {/* Left side */}
         <div className="grw-path-nav-container">

+ 4 - 13
packages/app/src/components/SearchPage/SortControl.tsx

@@ -25,15 +25,6 @@ const SortControl: FC <Props> = (props: Props) => {
     return <i className={iconClassName} aria-hidden="true" />;
   };
 
-  const renderSortItem = (sort, order) => {
-    return (
-      <div className="d-flex align-items-center justify-content-between w-100">
-        <span className="mr-3">{t(`search_result.sort_axis.${sort}`)}</span>
-        {renderOrderIcon(order)}
-      </div>
-    );
-  };
-
   return (
     <>
       <div className="input-group">
@@ -42,10 +33,10 @@ const SortControl: FC <Props> = (props: Props) => {
             {renderOrderIcon(props.order)}
           </div>
         </div>
-        <div className="btn-group" role="group">
+        <div className="border rounded-right">
           <button
             type="button"
-            className="btn border dropdown-toggle"
+            className="btn dropdown-toggle"
             data-toggle="dropdown"
           >
             <span className="mr-4 text-secondary">{t(`search_result.sort_axis.${props.sort}`)}</span>
@@ -56,11 +47,11 @@ const SortControl: FC <Props> = (props: Props) => {
               return (
                 <button
                   key={sortAxis}
-                  className="dropdown-item d-flex justify-content-between"
+                  className="dropdown-item"
                   type="button"
                   onClick={() => { onClickChangeSort(sortAxis, nextOrder) }}
                 >
-                  {renderSortItem(sortAxis, nextOrder)}
+                  <span>{t(`search_result.sort_axis.${sortAxis}`)}</span>
                 </button>
               );
             })}

+ 6 - 5
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -86,8 +86,9 @@ const ItemControl: FC<ItemControlProps> = memo((props: ItemControlProps) => {
 const ItemCount: FC = () => {
   return (
     <>
-      <span className="grw-pagetree-count badge badge-pill badge-light">
+      <span className="grw-pagetree-count badge badge-pill badge-light text-muted">
         {/* TODO: consider to show the number of children pages */}
+        00
       </span>
     </>
   );
@@ -139,8 +140,8 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   const inputValidator = (title: string | null): AlertInfo | null => {
     if (title == null || title === '') {
       return {
-        type: AlertType.ERROR,
-        message: t('Page title is required'),
+        type: AlertType.WARNING,
+        message: t('form_validation.title_required'),
       };
     }
 
@@ -191,7 +192,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
           </div>
         </button>
         <a href={page._id} className="grw-pagetree-title-anchor flex-grow-1">
-          <p className={`grw-pagetree-title m-auto ${page.isEmpty && 'text-muted'}`}>{nodePath.basename(page.path as string) || '/'}</p>
+          <p className={`text-truncate m-auto ${page.isEmpty && 'text-muted'}`}>{nodePath.basename(page.path as string) || '/'}</p>
         </a>
         <div className="grw-pagetree-count-wrapper">
           <ItemCount />
@@ -218,7 +219,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       )}
       {
         isOpen && hasChildren() && currentChildren.map(node => (
-          <div key={node.page._id} className="grw-pagetree-item-container mt-2">
+          <div key={node.page._id} className="grw-pagetree-item-container">
             <Item
               isEnableActions={isEnableActions}
               itemNode={node}

+ 3 - 0
packages/app/src/interfaces/indeterminate-input-elm.ts

@@ -0,0 +1,3 @@
+export interface IndeterminateInputElement extends HTMLInputElement {
+  indeterminate:boolean
+}

+ 51 - 19
packages/app/src/server/service/page.js

@@ -865,23 +865,57 @@ class PageService {
     }
   }
 
+  async _isPagePathIndexUnique() {
+    const Page = this.crowi.model('Page');
+    const now = (new Date()).toString();
+    const path = `growi_check_is_path_index_unique_${now}`;
+
+    let isUnique = false;
+
+    try {
+      await Page.insertMany([
+        { path },
+        { path },
+      ]);
+    }
+    catch (err) {
+      if (err?.code === 11000) { // Error code 11000 indicates the index is unique
+        isUnique = true;
+        logger.info('Page path index is unique.');
+      }
+      else {
+        throw err;
+      }
+    }
+    finally {
+      await Page.deleteMany({ path: { $regex: new RegExp('growi_check_is_path_index_unique', 'g') } });
+    }
+
+
+    return isUnique;
+  }
+
+  // TODO: use socket to send status to the client
   async v5InitialMigration(grant) {
     // const socket = this.crowi.socketIoService.getAdminSocket();
-    const Page = this.crowi.model('Page');
-    const indexStatus = await Page.aggregate([{ $indexStats: {} }]);
-    const pathIndexStatus = indexStatus.filter(status => status.name === 'path_1')?.[0];
-    const isPathIndexExists = pathIndexStatus != null;
-    const isUnique = isPathIndexExists && pathIndexStatus.spec?.unique === true;
+
+    let isUnique;
+    try {
+      isUnique = await this._isPagePathIndexUnique();
+    }
+    catch (err) {
+      logger.error('Failed to check path index status', err);
+      throw err;
+    }
 
     // drop unique index first
-    if (isUnique || !isPathIndexExists) {
+    if (isUnique) {
       try {
-        await this._v5NormalizeIndex(isPathIndexExists);
+        await this._v5NormalizeIndex();
       }
       catch (err) {
         logger.error('V5 index normalization failed.', err);
         // socket.emit('v5IndexNormalizationFailed', { error: err.message });
-
         throw err;
       }
     }
@@ -1078,19 +1112,17 @@ class PageService {
 
   }
 
-  async _v5NormalizeIndex(isPathIndexExists) {
+  async _v5NormalizeIndex() {
     const collection = mongoose.connection.collection('pages');
 
-    if (isPathIndexExists) {
-      try {
-        // drop pages.path_1 indexes
-        await collection.dropIndex('path_1');
-        logger.info('Succeeded to drop unique indexes from pages.path.');
-      }
-      catch (err) {
-        logger.warn('Failed to drop unique indexes from pages.path.', err);
-        throw err;
-      }
+    try {
+      // drop pages.path_1 indexes
+      await collection.dropIndex('path_1');
+      logger.info('Succeeded to drop unique indexes from pages.path.');
+    }
+    catch (err) {
+      logger.warn('Failed to drop unique indexes from pages.path.', err);
+      throw err;
     }
 
     try {

+ 9 - 10
packages/app/src/stores/bookmark.ts

@@ -3,14 +3,13 @@ import { apiv3Get } from '../client/util/apiv3-client';
 import { IBookmarkInfo } from '../interfaces/bookmark-info';
 
 
-export const useSWRBookmarkInfo = (pageId: string): SWRResponse<IBookmarkInfo, Error> => {
-  return useSWR(
-    `/bookmarks/info?pageId=${pageId}`,
-    endpoint => apiv3Get(endpoint).then((response) => {
-      return {
-        sumOfBookmarks: response.data.sumOfBookmarks,
-        isBookmarked: response.data.isBookmarked,
-      };
-    }),
-  );
+export const useSWRBookmarkInfo = (pageId: string | null): SWRResponse<IBookmarkInfo, Error> => {
+  return useSWR(pageId != null
+    ? `/bookmarks/info?pageId=${pageId}` : null,
+  endpoint => apiv3Get(endpoint).then((response) => {
+    return {
+      sumOfBookmarks: response.data.sumOfBookmarks,
+      isBookmarked: response.data.isBookmarked,
+    };
+  }));
 };

+ 2 - 2
packages/app/src/stores/page.tsx

@@ -46,8 +46,8 @@ export const useSWRxPageList = (
   );
 };
 
-export const useSWRPageInfo = (pageId: string): SWRResponse<IPageInfo, Error> => {
-  return useSWR(`/page/info?pageId=${pageId}`, endpoint => apiv3Get(endpoint).then((response) => {
+export const useSWRPageInfo = (pageId: string | null): SWRResponse<IPageInfo, Error> => {
+  return useSWR(pageId != null ? `/page/info?pageId=${pageId}` : null, endpoint => apiv3Get(endpoint).then((response) => {
     return {
       sumOfLikers: response.data.sumOfLikers,
       likerIds: response.data.likerIds,

+ 1 - 7
packages/app/src/styles/_page-tree.scss

@@ -34,12 +34,6 @@ $grw-pagetree-item-padding-left: 10px;
       width: 100%;
       overflow: hidden;
       text-decoration: none;
-
-      .grw-pagetree-title {
-        overflow: hidden;
-        font-size: medium;
-        text-overflow: ellipsis;
-      }
     }
 
     .grw-pagetree-count-wrapper {
@@ -50,7 +44,7 @@ $grw-pagetree-item-padding-left: 10px;
       }
 
       .grw-pagetree-count {
-        padding: 0.3rem 1rem;
+        padding: 0.1rem 0.3rem;
       }
     }
   }

+ 1 - 0
packages/app/src/styles/_search.scss

@@ -182,6 +182,7 @@
   .search-result-list {
     position: sticky;
     top: 0px;
+    z-index: 10; // to avoid dropdown menu in this class to be placed behind elements displayed on the right pane
 
     .search-result-list-scroll {
       // subtract the height of GrowiNavbar + (SearchControl component + other factors)

+ 5 - 1
packages/app/src/styles/theme/_apply-colors-dark.scss

@@ -259,7 +259,11 @@ ul.pagination {
         background: $bgcolor-list-hover;
       }
 
-      .grw-triangle-icon {
+      .grw-pagetree-count {
+        background: $bgcolor-sidebar-list-group;
+      }
+
+      .grw-pagetree-button {
         &:not(:hover) {
           svg {
             fill: $gray-500;

+ 5 - 1
packages/app/src/styles/theme/_apply-colors-light.scss

@@ -176,7 +176,11 @@ $border-color: $border-color-global;
         background: $bgcolor-list-hover;
       }
 
-      .grw-triangle-icon {
+      .grw-pagetree-count {
+        background: $bgcolor-sidebar-list-group;
+      }
+
+      .grw-pagetree-button {
         &:not(:hover) {
           svg {
             fill: $gray-400;