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

Merge branch 'master' into feat/separate-v5-compatible-db-for-test

Taichi Masuyama 4 лет назад
Родитель
Сommit
deda0249ba
51 измененных файлов с 449 добавлено и 198 удалено
  1. 1 0
      packages/app/.env.development
  2. 2 0
      packages/app/config/ci/.env.local.for-auto-install
  3. 123 0
      packages/app/resource/search/mappings-es6-for-ci.json
  4. 1 1
      packages/app/src/client/app.jsx
  5. 2 2
      packages/app/src/components/Admin/AdminHome/AdminHome.jsx
  6. 2 2
      packages/app/src/components/Admin/App/AppSettingsPageContents.jsx
  7. 2 2
      packages/app/src/components/Admin/Customize/Customize.jsx
  8. 2 2
      packages/app/src/components/Admin/ExportArchiveDataPage.jsx
  9. 2 2
      packages/app/src/components/Admin/FullTextSearchManagement.jsx
  10. 2 2
      packages/app/src/components/Admin/ImportData/ImportDataPageContents.jsx
  11. 2 2
      packages/app/src/components/Admin/LegacySlackIntegration/LegacySlackIntegration.jsx
  12. 2 2
      packages/app/src/components/Admin/MarkdownSetting/MarkDownSettingContents.jsx
  13. 2 2
      packages/app/src/components/Admin/Notification/NotificationSetting.jsx
  14. 2 2
      packages/app/src/components/Admin/Security/SecurityManagementContents.jsx
  15. 2 2
      packages/app/src/components/Admin/SlackIntegration/SlackIntegration.jsx
  16. 2 2
      packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx
  17. 2 2
      packages/app/src/components/Admin/UserManagement.jsx
  18. 1 0
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  19. 1 0
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  20. 7 1
      packages/app/src/components/Navbar/GrowiSubNavigation.tsx
  21. 6 1
      packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.jsx
  22. 3 1
      packages/app/src/components/Navbar/SubNavButtons.tsx
  23. 1 1
      packages/app/src/components/Page/DisplaySwitcher.tsx
  24. 14 3
      packages/app/src/components/PageCreateModal.jsx
  25. 3 0
      packages/app/src/components/SearchForm.tsx
  26. 12 7
      packages/app/src/components/SearchPage.tsx
  27. 9 5
      packages/app/src/components/SearchPage/SearchControl.tsx
  28. 0 71
      packages/app/src/components/SearchPage/SearchPageForm.jsx
  29. 11 6
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  30. 1 1
      packages/app/src/components/SearchPage/SearchResultList.tsx
  31. 1 1
      packages/app/src/components/SearchPage/SortControl.tsx
  32. 30 16
      packages/app/src/components/SearchPage2/SearchPageBase.tsx
  33. 5 4
      packages/app/src/components/SearchTypeahead.tsx
  34. 1 1
      packages/app/src/components/TrashPageList.jsx
  35. 2 1
      packages/app/src/server/crowi/index.js
  36. 1 1
      packages/app/src/server/models/page.ts
  37. 4 3
      packages/app/src/server/routes/search.js
  38. 11 2
      packages/app/src/server/service/config-loader.ts
  39. 27 5
      packages/app/src/server/service/installer.ts
  40. 32 11
      packages/app/src/server/service/search-delegator/elasticsearch.ts
  41. 1 1
      packages/app/src/server/views/layout-growi/page_list.html
  42. 1 1
      packages/app/src/server/views/tags.html
  43. 2 3
      packages/app/src/styles/_search.scss
  44. 9 4
      packages/app/src/styles/_subnav.scss
  45. 1 1
      packages/app/src/styles/theme/_apply-colors.scss
  46. 13 0
      packages/app/test/cypress/integration/2-basic-features/access-to-admin-page.spec.ts
  47. 5 0
      packages/app/test/cypress/integration/2-basic-features/access-to-me-page.spec.ts
  48. 0 15
      packages/app/test/cypress/integration/2-basic-features/access-to-page.spec.ts
  49. 35 0
      packages/app/test/cypress/integration/2-basic-features/access-to-special-page.spec.ts
  50. 37 0
      packages/app/test/cypress/integration/2-basic-features/open-page-create-modal.spec.ts
  51. 9 4
      packages/app/test/cypress/integration/3-search/access-to-result-page-directly.spec.ts

+ 1 - 0
packages/app/.env.development

@@ -4,6 +4,7 @@
 ##
 ##
 MIGRATIONS_DIR=src/migrations/
 MIGRATIONS_DIR=src/migrations/
 
 
+APP_SITE_URL=http://localhost:3000
 FILE_UPLOAD=mongodb
 FILE_UPLOAD=mongodb
 # MONGO_GRIDFS_TOTAL_LIMIT=10485760
 # MONGO_GRIDFS_TOTAL_LIMIT=10485760
 MATHJAX=1
 MATHJAX=1

+ 2 - 0
packages/app/config/ci/.env.local.for-auto-install

@@ -5,3 +5,5 @@ AUTO_INSTALL_ADMIN_NAME=Admin
 AUTO_INSTALL_ADMIN_EMAIL=admin@example.com
 AUTO_INSTALL_ADMIN_EMAIL=admin@example.com
 AUTO_INSTALL_ADMIN_PASSWORD=adminadmin
 AUTO_INSTALL_ADMIN_PASSWORD=adminadmin
 AUTO_INSTALL_GLOBAL_LANG=en_US
 AUTO_INSTALL_GLOBAL_LANG=en_US
+
+AUTO_INSTALL_SERVER_DATE=2022-01-01T00:00:00.0

+ 123 - 0
packages/app/resource/search/mappings-es6-for-ci.json

@@ -0,0 +1,123 @@
+{
+  "settings": {
+    "analysis": {
+      "filter": {
+        "english_stop": {
+          "type":       "stop",
+          "stopwords":  "_english_"
+        }
+      },
+      "tokenizer": {
+        "edge_ngram_tokenizer": {
+          "type": "edge_ngram",
+          "min_gram": 2,
+          "max_gram": 20,
+          "token_chars": ["letter", "digit"]
+        }
+      },
+      "analyzer": {
+        "japanese": {
+          "tokenizer": "edge_ngram_tokenizer",
+          "filter": [
+            "lowercase",
+            "english_stop"
+          ]
+        },
+        "english_edge_ngram": {
+          "tokenizer": "edge_ngram_tokenizer",
+          "filter": [
+            "lowercase",
+            "english_stop"
+          ]
+        }
+      }
+    }
+  },
+  "mappings": {
+    "pages": {
+      "properties" : {
+        "path": {
+          "type": "text",
+          "fields": {
+            "raw": {
+              "type": "text",
+              "analyzer": "keyword"
+            },
+            "ja": {
+              "type": "text",
+              "analyzer": "japanese"
+            },
+            "en": {
+              "type": "text",
+              "analyzer": "english_edge_ngram",
+              "search_analyzer": "standard"
+            }
+          }
+        },
+        "body": {
+          "type": "text",
+          "fields": {
+            "ja": {
+              "type": "text",
+              "analyzer": "japanese"
+            },
+            "en": {
+              "type": "text",
+              "analyzer": "english_edge_ngram",
+              "search_analyzer": "standard"
+            }
+          }
+        },
+        "comments": {
+          "type": "text",
+          "fields": {
+            "ja": {
+              "type": "text",
+              "analyzer": "japanese"
+            },
+            "en": {
+              "type": "text",
+              "analyzer": "english_edge_ngram",
+              "search_analyzer": "standard"
+            }
+          }
+        },
+        "username": {
+          "type": "keyword"
+        },
+        "comment_count": {
+          "type": "integer"
+        },
+        "bookmark_count": {
+          "type": "integer"
+        },
+        "seenUsers_count":{
+          "type": "integer"
+        },
+        "like_count": {
+          "type": "integer"
+        },
+        "grant": {
+          "type": "integer"
+        },
+        "granted_users": {
+          "type": "keyword"
+        },
+        "granted_group": {
+          "type": "keyword"
+        },
+        "created_at": {
+          "type": "date",
+          "format": "dateOptionalTime"
+        },
+        "updated_at": {
+          "type": "date",
+          "format": "dateOptionalTime"
+        },
+        "tag_names": {
+          "type": "keyword"
+        }
+      }
+    }
+  }
+}

+ 1 - 1
packages/app/src/client/app.jsx

@@ -96,7 +96,7 @@ Object.assign(componentMappings, {
 
 
   'trash-page-alert': <TrashPageAlert />,
   'trash-page-alert': <TrashPageAlert />,
 
 
-  'trash-page-list': <TrashPageList />,
+  'trash-page-list-container': <TrashPageList />,
 
 
   'not-found-page': <NotFoundPage />,
   'not-found-page': <NotFoundPage />,
 
 

+ 2 - 2
packages/app/src/components/Admin/AdminHome/AdminHome.jsx

@@ -37,7 +37,7 @@ const AdminHome = (props) => {
   }, [fetchAdminHomeData]);
   }, [fetchAdminHomeData]);
 
 
   return (
   return (
-    <>
+    <div data-testid="admin-home">
       {
       {
       // Alert message will be displayed in case that V5 migration has not been compleated
       // Alert message will be displayed in case that V5 migration has not been compleated
         (migrationStatus != null && !migrationStatus.isV5Compatible)
         (migrationStatus != null && !migrationStatus.isV5Compatible)
@@ -106,7 +106,7 @@ const AdminHome = (props) => {
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>
-    </>
+    </div>
   );
   );
 };
 };
 
 

+ 2 - 2
packages/app/src/components/Admin/App/AppSettingsPageContents.jsx

@@ -19,7 +19,7 @@ class AppSettingsPageContents extends React.Component {
     const { isV5Compatible } = adminAppContainer.state;
     const { isV5Compatible } = adminAppContainer.state;
 
 
     return (
     return (
-      <Fragment>
+      <div data-testid="admin-app-settings">
         {
         {
           !isV5Compatible
           !isV5Compatible
           && (
           && (
@@ -66,7 +66,7 @@ class AppSettingsPageContents extends React.Component {
             <PluginSetting />
             <PluginSetting />
           </div>
           </div>
         </div>
         </div>
-      </Fragment>
+      </div>
     );
     );
   }
   }
 
 

+ 2 - 2
packages/app/src/components/Admin/Customize/Customize.jsx

@@ -46,7 +46,7 @@ function Customize(props) {
   }
   }
 
 
   return (
   return (
-    <Fragment>
+    <div data-testid="admin-customize">
       <div className="mb-5">
       <div className="mb-5">
         <CustomizeLayoutSetting appContainer={appContainer} />
         <CustomizeLayoutSetting appContainer={appContainer} />
       </div>
       </div>
@@ -71,7 +71,7 @@ function Customize(props) {
       <div className="mb-5">
       <div className="mb-5">
         <CustomizeScriptSetting />
         <CustomizeScriptSetting />
       </div>
       </div>
-    </Fragment>
+    </div>
   );
   );
 }
 }
 
 

+ 2 - 2
packages/app/src/components/Admin/ExportArchiveDataPage.jsx

@@ -210,7 +210,7 @@ class ExportArchiveDataPage extends React.Component {
     const showExportingData = (isExported || isExporting) && (progressList != null);
     const showExportingData = (isExported || isExporting) && (progressList != null);
 
 
     return (
     return (
-      <Fragment>
+      <div data-testid="admin-export-archive-data">
         <h2>{t('Export Archive Data')}</h2>
         <h2>{t('Export Archive Data')}</h2>
 
 
         <button type="button" className="btn btn-outline-secondary" disabled={isExporting} onClick={this.openExportModal}>
         <button type="button" className="btn btn-outline-secondary" disabled={isExporting} onClick={this.openExportModal}>
@@ -239,7 +239,7 @@ class ExportArchiveDataPage extends React.Component {
           onClose={this.closeExportModal}
           onClose={this.closeExportModal}
           collections={this.state.collections}
           collections={this.state.collections}
         />
         />
-      </Fragment>
+      </div>
     );
     );
   }
   }
 
 

+ 2 - 2
packages/app/src/components/Admin/FullTextSearchManagement.jsx

@@ -14,10 +14,10 @@ class FullTextSearchManagement extends React.Component {
     const { t } = this.props;
     const { t } = this.props;
 
 
     return (
     return (
-      <Fragment>
+      <div data-testid="admin-full-text-search">
         <h2> { t('full_text_search_management.elasticsearch_management') } </h2>
         <h2> { t('full_text_search_management.elasticsearch_management') } </h2>
         <ElasticsearchManagement />
         <ElasticsearchManagement />
-      </Fragment>
+      </div>
     );
     );
   }
   }
 
 

+ 2 - 2
packages/app/src/components/Admin/ImportData/ImportDataPageContents.jsx

@@ -13,7 +13,7 @@ class ImportDataPageContents extends React.Component {
     const { t, adminImportContainer } = this.props;
     const { t, adminImportContainer } = this.props;
 
 
     return (
     return (
-      <Fragment>
+      <div data-testid="admin-import-data">
         <GrowiArchiveSection />
         <GrowiArchiveSection />
 
 
         <form
         <form
@@ -226,7 +226,7 @@ class ImportDataPageContents extends React.Component {
 
 
 
 
         </form>
         </form>
-      </Fragment>
+      </div>
     );
     );
   }
   }
 
 

+ 2 - 2
packages/app/src/components/Admin/LegacySlackIntegration/LegacySlackIntegration.jsx

@@ -42,7 +42,7 @@ function LegacySlackIntegration(props) {
   const isDisabled = adminSlackIntegrationLegacyContainer.state.isSlackbotConfigured;
   const isDisabled = adminSlackIntegrationLegacyContainer.state.isSlackbotConfigured;
 
 
   return (
   return (
-    <>
+    <div data-testid="admin-slack-integration-legacy">
       { isDisabled && (
       { isDisabled && (
         <div className="alert alert-danger">
         <div className="alert alert-danger">
           <i className="icon-minus icon-fw"></i>
           <i className="icon-minus icon-fw"></i>
@@ -58,7 +58,7 @@ function LegacySlackIntegration(props) {
       </div>
       </div>
 
 
       <SlackConfiguration />
       <SlackConfiguration />
-    </>
+    </div>
   );
   );
 }
 }
 
 

+ 2 - 2
packages/app/src/components/Admin/MarkdownSetting/MarkDownSettingContents.jsx

@@ -14,7 +14,7 @@ class MarkDownSettingContents extends React.Component {
   render() {
   render() {
     const { t } = this.props;
     const { t } = this.props;
     return (
     return (
-      <React.Fragment>
+      <div data-testid="admin-markdown">
         {/* Line Break Setting */}
         {/* Line Break Setting */}
         <h2 className="admin-setting-header">{t('admin:markdown_setting.lineBreak_header')}</h2>
         <h2 className="admin-setting-header">{t('admin:markdown_setting.lineBreak_header')}</h2>
         <Card className="card well my-3">
         <Card className="card well my-3">
@@ -42,7 +42,7 @@ class MarkDownSettingContents extends React.Component {
           <CardBody className="px-0 py-2">{ t('admin:markdown_setting.xss_desc') }</CardBody>
           <CardBody className="px-0 py-2">{ t('admin:markdown_setting.xss_desc') }</CardBody>
         </Card>
         </Card>
         <XssForm />
         <XssForm />
-      </React.Fragment>
+      </div>
     );
     );
   }
   }
 
 

+ 2 - 2
packages/app/src/components/Admin/Notification/NotificationSetting.jsx

@@ -144,7 +144,7 @@ function NotificationSetting(props) {
   const isSlackLegacyEnabled = !isSlackbotConfigured && isSlackLegacyConfigured;
   const isSlackLegacyEnabled = !isSlackbotConfigured && isSlackLegacyConfigured;
 
 
   return (
   return (
-    <>
+    <div data-testid="admin-notification">
       <h2 className="admin-setting-header">{t('admin:external_notification.header_status')}</h2>
       <h2 className="admin-setting-header">{t('admin:external_notification.header_status')}</h2>
       <ul className="list-group">
       <ul className="list-group">
         { !isMounted && <SkeltonListItem />}
         { !isMounted && <SkeltonListItem />}
@@ -170,7 +170,7 @@ function NotificationSetting(props) {
           {activeComponents.has('global_notification') && <GlobalNotification />}
           {activeComponents.has('global_notification') && <GlobalNotification />}
         </TabPane>
         </TabPane>
       </TabContent>
       </TabContent>
-    </>
+    </div>
   );
   );
 }
 }
 
 

+ 2 - 2
packages/app/src/components/Admin/Security/SecurityManagementContents.jsx

@@ -81,7 +81,7 @@ function SecurityManagementContents(props) {
 
 
 
 
   return (
   return (
-    <Fragment>
+    <div data-testid="admin-security">
       <div className="mb-5">
       <div className="mb-5">
         <SecuritySetting />
         <SecuritySetting />
       </div>
       </div>
@@ -141,7 +141,7 @@ function SecurityManagementContents(props) {
           </TabPane>
           </TabPane>
         </TabContent>
         </TabContent>
       </div>
       </div>
-    </Fragment>
+    </div>
   );
   );
 
 
 }
 }

+ 2 - 2
packages/app/src/components/Admin/SlackIntegration/SlackIntegration.jsx

@@ -192,7 +192,7 @@ const SlackIntegration = (props) => {
   }
   }
 
 
   return (
   return (
-    <>
+    <div data-testid="admin-slack-integration">
       <ConfirmBotChangeModal
       <ConfirmBotChangeModal
         isOpen={selectedBotType != null}
         isOpen={selectedBotType != null}
         onConfirmClick={changeCurrentBotSettingsHandler}
         onConfirmClick={changeCurrentBotSettingsHandler}
@@ -246,7 +246,7 @@ const SlackIntegration = (props) => {
       </div>
       </div>
 
 
       {settingsComponent}
       {settingsComponent}
-    </>
+    </div>
   );
   );
 };
 };
 
 

+ 2 - 2
packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx

@@ -111,7 +111,7 @@ const UserGroupPage: FC<Props> = (props: Props) => {
   }, [mutateUserGroups]);
   }, [mutateUserGroups]);
 
 
   return (
   return (
-    <Fragment>
+    <div data-testid="admin-user-groups">
       {
       {
         isAclEnabled ? (
         isAclEnabled ? (
           <div className="mb-2">
           <div className="mb-2">
@@ -147,7 +147,7 @@ const UserGroupPage: FC<Props> = (props: Props) => {
           onHide={hideDeleteModal}
           onHide={hideDeleteModal}
         />
         />
       </>
       </>
-    </Fragment>
+    </div>
   );
   );
 };
 };
 
 

+ 2 - 2
packages/app/src/components/Admin/UserManagement.jsx

@@ -141,7 +141,7 @@ class UserManagement extends React.Component {
     );
     );
 
 
     return (
     return (
-      <Fragment>
+      <div data-testid="admin-users">
         {adminUsersContainer.state.userForPasswordResetModal != null
         {adminUsersContainer.state.userForPasswordResetModal != null
         && (
         && (
           <PasswordResetModal
           <PasswordResetModal
@@ -212,7 +212,7 @@ class UserManagement extends React.Component {
         <UserTable />
         <UserTable />
         {pager}
         {pager}
 
 
-      </Fragment>
+      </div>
     );
     );
   }
   }
 
 

+ 1 - 0
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -282,6 +282,7 @@ const GrowiContextualSubNavigation = (props) => {
       tags={tagsInfoData?.tags || []}
       tags={tagsInfoData?.tags || []}
       tagsUpdatedHandler={tagsUpdatedHandler}
       tagsUpdatedHandler={tagsUpdatedHandler}
       controls={ControlComponents}
       controls={ControlComponents}
+      additionalClasses={['container-fluid']}
     />
     />
   );
   );
 };
 };

+ 1 - 0
packages/app/src/components/Navbar/GrowiNavbar.tsx

@@ -44,6 +44,7 @@ const NavbarRight: FC<NavbarRightProps> = memo((props: NavbarRightProps) => {
         <button
         <button
           className="px-md-3 nav-link btn-create-page border-0 bg-transparent"
           className="px-md-3 nav-link btn-create-page border-0 bg-transparent"
           type="button"
           type="button"
+          data-testid="newPageBtn"
           onClick={() => openCreateModal(currentPagePath || '')}
           onClick={() => openCreateModal(currentPagePath || '')}
         >
         >
           <i className="icon-pencil mr-2"></i>
           <i className="icon-pencil mr-2"></i>

+ 7 - 1
packages/app/src/components/Navbar/GrowiSubNavigation.tsx

@@ -30,6 +30,7 @@ type Props = {
   tagsUpdatedHandler?: (newTags: string[]) => Promise<void>,
   tagsUpdatedHandler?: (newTags: string[]) => Promise<void>,
 
 
   controls?: React.FunctionComponent,
   controls?: React.FunctionComponent,
+  additionalClasses?: string[],
 }
 }
 
 
 export const GrowiSubNavigation = (props: Props): JSX.Element => {
 export const GrowiSubNavigation = (props: Props): JSX.Element => {
@@ -41,6 +42,7 @@ export const GrowiSubNavigation = (props: Props): JSX.Element => {
     isGuestUser, isDrawerMode, isCompactMode,
     isGuestUser, isDrawerMode, isCompactMode,
     tags, tagsUpdatedHandler,
     tags, tagsUpdatedHandler,
     controls: Controls,
     controls: Controls,
+    additionalClasses = [],
   } = props;
   } = props;
 
 
   const {
   const {
@@ -56,7 +58,11 @@ export const GrowiSubNavigation = (props: Props): JSX.Element => {
   }
   }
 
 
   return (
   return (
-    <div className={`grw-subnav container-fluid d-flex align-items-center justify-content-between ${isCompactMode ? 'grw-subnav-compact d-print-none' : ''}`}>
+    <div className={
+      'grw-subnav d-flex align-items-center justify-content-between'
+      + ` ${additionalClasses.join(' ')}`
+      + ` ${isCompactMode ? 'grw-subnav-compact d-print-none' : ''}`}
+    >
 
 
       {/* Left side */}
       {/* Left side */}
       <div className="d-flex grw-subnav-left-side">
       <div className="d-flex grw-subnav-left-side">

+ 6 - 1
packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.jsx

@@ -110,7 +110,12 @@ const GrowiSubNavigationSwitcher = (props) => {
 
 
   return (
   return (
     <div className={`grw-subnav-switcher ${isVisible ? '' : 'grw-subnav-switcher-hidden'}`}>
     <div className={`grw-subnav-switcher ${isVisible ? '' : 'grw-subnav-switcher-hidden'}`}>
-      <div id="grw-subnav-fixed-container" className="grw-subnav-fixed-container position-fixed" ref={fixedContainerRef} style={{ width }}>
+      <div
+        id="grw-subnav-fixed-container"
+        className="grw-subnav-fixed-container position-fixed grw-subnav-append-shadow-container"
+        ref={fixedContainerRef}
+        style={{ width }}
+      >
         <GrowiContextualSubNavigation isCompactMode isLinkSharingDisabled />
         <GrowiContextualSubNavigation isCompactMode isLinkSharingDisabled />
       </div>
       </div>
     </div>
     </div>

+ 3 - 1
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -154,7 +154,9 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
         bookmarkedUsers={bookmarkInfo?.bookmarkedUsers}
         bookmarkedUsers={bookmarkInfo?.bookmarkedUsers}
         onBookMarkClicked={bookmarkClickHandler}
         onBookMarkClicked={bookmarkClickHandler}
       />
       />
-      <SeenUserInfo seenUsers={seenUsers} disabled={disableSeenUserInfoPopover} />
+      { !isCompactMode && (
+        <SeenUserInfo seenUsers={seenUsers} disabled={disableSeenUserInfoPopover} />
+      ) }
       { showPageControlDropdown && (
       { showPageControlDropdown && (
         <PageItemControl
         <PageItemControl
           pageId={pageId}
           pageId={pageId}

+ 1 - 1
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -112,7 +112,7 @@ const DisplaySwitcher = (): JSX.Element => {
         </TabPane>
         </TabPane>
         { isEditable && (
         { isEditable && (
           <TabPane tabId={EditorMode.Editor}>
           <TabPane tabId={EditorMode.Editor}>
-            <div id="page-editor">
+            <div data-testid="page-editor" id="page-editor">
               <Editor />
               <Editor />
             </div>
             </div>
           </TabPane>
           </TabPane>

+ 14 - 3
packages/app/src/components/PageCreateModal.jsx

@@ -165,7 +165,12 @@ const PageCreateModal = (props) => {
             </div>
             </div>
 
 
             <div className="d-flex justify-content-end mt-1 mt-sm-0">
             <div className="d-flex justify-content-end mt-1 mt-sm-0">
-              <button type="button" className="grw-btn-create-page btn btn-outline-primary rounded-pill text-nowrap ml-3" onClick={createTodayPage}>
+              <button
+                type="button"
+                data-testid="btn-create-memo"
+                className="grw-btn-create-page btn btn-outline-primary rounded-pill text-nowrap ml-3"
+                onClick={createTodayPage}
+              >
                 <i className="icon-fw icon-doc"></i>{t('Create')}
                 <i className="icon-fw icon-doc"></i>{t('Create')}
               </button>
               </button>
             </div>
             </div>
@@ -179,7 +184,7 @@ const PageCreateModal = (props) => {
 
 
   function renderInputPageForm() {
   function renderInputPageForm() {
     return (
     return (
-      <div className="row">
+      <div className="row" data-testid="row-create-page-under-below">
         <fieldset className="col-12 mb-4">
         <fieldset className="col-12 mb-4">
           <h3 className="grw-modal-head pb-2">{t('Create under')}</h3>
           <h3 className="grw-modal-head pb-2">{t('Create under')}</h3>
 
 
@@ -211,7 +216,12 @@ const PageCreateModal = (props) => {
             </div>
             </div>
 
 
             <div className="d-flex justify-content-end mt-1 mt-sm-0">
             <div className="d-flex justify-content-end mt-1 mt-sm-0">
-              <button type="button" className="grw-btn-create-page btn btn-outline-primary rounded-pill text-nowrap ml-3" onClick={createInputPage}>
+              <button
+                type="button"
+                data-testid="btn-create-page-under-below"
+                className="grw-btn-create-page btn btn-outline-primary rounded-pill text-nowrap ml-3"
+                onClick={createInputPage}
+              >
                 <i className="icon-fw icon-doc"></i>{t('Create')}
                 <i className="icon-fw icon-doc"></i>{t('Create')}
               </button>
               </button>
             </div>
             </div>
@@ -275,6 +285,7 @@ const PageCreateModal = (props) => {
       size="lg"
       size="lg"
       isOpen={isOpened}
       isOpen={isOpened}
       toggle={() => closeCreateModal()}
       toggle={() => closeCreateModal()}
+      data-testid="page-create-modal"
       className="grw-create-page"
       className="grw-create-page"
       autoFocus={false}
       autoFocus={false}
     >
     >

+ 3 - 0
packages/app/src/components/SearchForm.tsx

@@ -85,6 +85,7 @@ type Props = {
 
 
   dropup?: boolean,
   dropup?: boolean,
   keyword?: string,
   keyword?: string,
+  disableIncrementalSearch?: boolean,
   onChange?: (data: IPageWithMeta<IPageSearchMeta>[]) => void,
   onChange?: (data: IPageWithMeta<IPageSearchMeta>[]) => void,
   onBlur?: () => void,
   onBlur?: () => void,
   onFocus?: () => void,
   onFocus?: () => void,
@@ -97,6 +98,7 @@ const SearchForm: ForwardRefRenderFunction<IFocusable, Props> = (props: Props, r
   const { t } = useTranslation();
   const { t } = useTranslation();
   const {
   const {
     isSearchServiceReachable, dropup,
     isSearchServiceReachable, dropup,
+    disableIncrementalSearch,
     onChange, onBlur, onFocus, onSubmit, onInputChange,
     onChange, onBlur, onFocus, onSubmit, onInputChange,
   } = props;
   } = props;
 
 
@@ -129,6 +131,7 @@ const SearchForm: ForwardRefRenderFunction<IFocusable, Props> = (props: Props, r
       dropup={dropup}
       dropup={dropup}
       emptyLabel={emptyLabel}
       emptyLabel={emptyLabel}
       placeholder={placeholder}
       placeholder={placeholder}
+      disableIncrementalSearch={disableIncrementalSearch}
       onChange={onChange}
       onChange={onChange}
       onSubmit={onSubmit}
       onSubmit={onSubmit}
       onInputChange={onInputChange}
       onInputChange={onInputChange}

+ 12 - 7
packages/app/src/components/SearchPage.tsx

@@ -47,16 +47,18 @@ const SearchResultListHead = React.memo((props: SearchResultListHeadProps): JSX.
   const rightNum = offset + hitsCount;
   const rightNum = offset + hitsCount;
 
 
   return (
   return (
-    <div className="d-flex align-items-center justify-content-between">
+    <div className="form-inline d-flex align-items-center justify-content-between">
       <div className="text-nowrap">
       <div className="text-nowrap">
         {t('search_result.result_meta')}
         {t('search_result.result_meta')}
-        <span className="search-result-keyword">{`"${searchingKeyword}"`}</span>
-        <span className="ml-3">{`${leftNum}-${rightNum}`} / {total}</span>
+        <span className="search-result-keyword">{`${searchingKeyword}`}</span>
+        { total > 0 && (
+          <span className="ml-3">{`${leftNum}-${rightNum}`} / {total}</span>
+        ) }
         { took != null && (
         { took != null && (
           <span className="ml-3 text-muted">({took}ms)</span>
           <span className="ml-3 text-muted">({took}ms)</span>
         ) }
         ) }
       </div>
       </div>
-      <div className="input-group search-result-select-group ml-4 d-lg-flex d-none">
+      <div className="input-group flex-nowrap search-result-select-group ml-auto d-md-flex d-none">
         <div className="input-group-prepend">
         <div className="input-group-prepend">
           <label className="input-group-text text-muted" htmlFor="inputGroupSelect01">{t('search_result.number_of_list_to_display')}</label>
           <label className="input-group-text text-muted" htmlFor="inputGroupSelect01">{t('search_result.number_of_list_to_display')}</label>
         </div>
         </div>
@@ -101,8 +103,7 @@ export const SearchPage = (props: Props): JSX.Element => {
   const initQ = (Array.isArray(parsedQueries) ? parsedQueries.join(' ') : parsedQueries) ?? '';
   const initQ = (Array.isArray(parsedQueries) ? parsedQueries.join(' ') : parsedQueries) ?? '';
 
 
   const [keyword, setKeyword] = useState<string>(initQ);
   const [keyword, setKeyword] = useState<string>(initQ);
-  const [configurationsByControl, setConfigurationsByControl] = useState<Partial<ISearchConfigurations>>({
-  });
+  const [configurationsByControl, setConfigurationsByControl] = useState<Partial<ISearchConfigurations>>({});
   const [configurationsByPagination, setConfigurationsByPagination] = useState<Partial<ISearchConfigurations>>({
   const [configurationsByPagination, setConfigurationsByPagination] = useState<Partial<ISearchConfigurations>>({
     limit: INITIAL_PAGIONG_SIZE,
     limit: INITIAL_PAGIONG_SIZE,
   });
   });
@@ -205,15 +206,19 @@ export const SearchPage = (props: Props): JSX.Element => {
   }, [hitsCount, selectAllCheckboxChangedHandler, t]);
   }, [hitsCount, selectAllCheckboxChangedHandler, t]);
 
 
   const searchControl = useMemo(() => {
   const searchControl = useMemo(() => {
+    if (!isSearchServiceReachable) {
+      return <></>;
+    }
     return (
     return (
       <SearchControl
       <SearchControl
+        isSearchServiceReachable={isSearchServiceReachable}
         initialSearchConditions={initialSearchConditions}
         initialSearchConditions={initialSearchConditions}
         onSearchInvoked={searchInvokedHandler}
         onSearchInvoked={searchInvokedHandler}
         deleteAllControl={deleteAllControl}
         deleteAllControl={deleteAllControl}
       >
       >
       </SearchControl>
       </SearchControl>
     );
     );
-  }, [deleteAllControl, initialSearchConditions, searchInvokedHandler]);
+  }, [deleteAllControl, initialSearchConditions, isSearchServiceReachable, searchInvokedHandler]);
 
 
   const searchResultListHead = useMemo(() => {
   const searchResultListHead = useMemo(() => {
     if (data == null) {
     if (data == null) {

+ 9 - 5
packages/app/src/components/SearchPage/SearchControl.tsx

@@ -6,11 +6,12 @@ import { useTranslation } from 'react-i18next';
 import { SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
 import { SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
 import { ISearchConditions, ISearchConfigurations } from '~/stores/search';
 import { ISearchConditions, ISearchConfigurations } from '~/stores/search';
 
 
-import SearchPageForm from './SearchPageForm';
 import SearchOptionModal from './SearchOptionModal';
 import SearchOptionModal from './SearchOptionModal';
 import SortControl from './SortControl';
 import SortControl from './SortControl';
+import SearchForm from '../SearchForm';
 
 
 type Props = {
 type Props = {
+  isSearchServiceReachable: boolean,
   initialSearchConditions: Partial<ISearchConditions>,
   initialSearchConditions: Partial<ISearchConditions>,
 
 
   onSearchInvoked: (keyword: string, configurations: Partial<ISearchConfigurations>) => void,
   onSearchInvoked: (keyword: string, configurations: Partial<ISearchConfigurations>) => void,
@@ -21,6 +22,7 @@ type Props = {
 const SearchControl: FC <Props> = React.memo((props: Props) => {
 const SearchControl: FC <Props> = React.memo((props: Props) => {
 
 
   const {
   const {
+    isSearchServiceReachable,
     initialSearchConditions,
     initialSearchConditions,
     onSearchInvoked,
     onSearchInvoked,
     deleteAllControl,
     deleteAllControl,
@@ -45,8 +47,8 @@ const SearchControl: FC <Props> = React.memo((props: Props) => {
     });
     });
   }, [keyword, sort, order, includeTrashPages, includeUserPages, onSearchInvoked]);
   }, [keyword, sort, order, includeTrashPages, includeUserPages, onSearchInvoked]);
 
 
-  const searchFormChangedHandler = useCallback(({ keyword }) => {
-    setKeyword(keyword as string);
+  const searchFormSubmittedHandler = useCallback((input: string) => {
+    setKeyword(input);
   }, []);
   }, []);
 
 
   const changeSortHandler = useCallback((nextSort: SORT_AXIS, nextOrder: SORT_ORDER) => {
   const changeSortHandler = useCallback((nextSort: SORT_AXIS, nextOrder: SORT_ORDER) => {
@@ -62,9 +64,11 @@ const SearchControl: FC <Props> = React.memo((props: Props) => {
     <div className="position-sticky fixed-top shadow-sm">
     <div className="position-sticky fixed-top shadow-sm">
       <div className="grw-search-page-nav d-flex py-3 align-items-center">
       <div className="grw-search-page-nav d-flex py-3 align-items-center">
         <div className="flex-grow-1 mx-4">
         <div className="flex-grow-1 mx-4">
-          <SearchPageForm
+          <SearchForm
+            isSearchServiceReachable={isSearchServiceReachable}
             keyword={keyword}
             keyword={keyword}
-            onSearchFormChanged={searchFormChangedHandler}
+            disableIncrementalSearch
+            onSubmit={searchFormSubmittedHandler}
           />
           />
         </div>
         </div>
 
 

+ 0 - 71
packages/app/src/components/SearchPage/SearchPageForm.jsx

@@ -1,71 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import SearchForm from '../SearchForm';
-import loggerFactory from '~/utils/logger';
-
-const logger = loggerFactory('growi:searchPageForm');
-
-// Search.SearchForm
-class SearchPageForm extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      keyword: this.props.keyword,
-      searchedKeyword: this.props.keyword,
-    };
-
-    this.search = this.search.bind(this);
-    this.onInputChange = this.onInputChange.bind(this);
-  }
-
-  search() {
-    if (this.props.onSearchFormChanged != null) {
-      const keyword = this.state.keyword;
-      this.props.onSearchFormChanged({ keyword });
-      this.setState({ searchedKeyword: keyword });
-    }
-    else {
-      throw new Error('onSearchFormChanged method is null');
-    }
-  }
-
-  onInputChange(input) { // for only submitting with button
-    this.setState({ keyword: input });
-  }
-
-  render() {
-    const { appContainer } = this.props;
-    const isSearchServiceReachable = appContainer.getConfig().isSearchServiceReachable;
-
-    return (
-      <SearchForm
-        isSearchServiceReachable={isSearchServiceReachable}
-        onSubmit={this.search}
-        keyword={this.state.searchedKeyword}
-        onInputChange={this.onInputChange}
-      />
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const SearchPageFormWrapper = withUnstatedContainers(SearchPageForm, [AppContainer]);
-
-SearchPageForm.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
-  keyword: PropTypes.string,
-  onSearchFormChanged: PropTypes.func,
-};
-SearchPageForm.defaultProps = {
-};
-
-export default SearchPageFormWrapper;

+ 11 - 6
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -138,6 +138,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
             path={page.path}
             path={page.path}
             showPageControlDropdown={showPageControlDropdown}
             showPageControlDropdown={showPageControlDropdown}
             additionalMenuItemRenderer={props => <AdditionalMenuItems {...props} pageId={page._id} revisionId={revisionId} />}
             additionalMenuItemRenderer={props => <AdditionalMenuItems {...props} pageId={page._id} revisionId={revisionId} />}
+            isCompactMode
             onClickDuplicateMenuItem={duplicateItemClickedHandler}
             onClickDuplicateMenuItem={duplicateItemClickedHandler}
             onClickRenameMenuItem={renameItemClickedHandler}
             onClickRenameMenuItem={renameItemClickedHandler}
             onClickDeleteMenuItem={deleteItemClickedHandler}
             onClickDeleteMenuItem={deleteItemClickedHandler}
@@ -153,12 +154,16 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
   if (page == null) return <></>;
   if (page == null) return <></>;
 
 
   return (
   return (
-    <div key={page._id} className="search-result-page grw-page-path-text-muted-container d-flex flex-column">
-      <GrowiSubNavigation
-        page={page}
-        controls={ControlComponents}
-      />
-      <div className="search-result-page-content" ref={scrollElementRef}>
+    <div key={page._id} data-testid="search-result-content" className="search-result-content grw-page-path-text-muted-container d-flex flex-column">
+      <div className="grw-subnav-append-shadow-container">
+        <GrowiSubNavigation
+          page={page}
+          controls={ControlComponents}
+          isCompactMode
+          additionalClasses={['px-4']}
+        />
+      </div>
+      <div className="search-result-content-body-container" ref={scrollElementRef}>
         <RevisionLoader
         <RevisionLoader
           growiRenderer={growiRenderer}
           growiRenderer={growiRenderer}
           pageId={page._id}
           pageId={page._id}

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

@@ -79,7 +79,7 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
   }
   }
 
 
   return (
   return (
-    <ul className="page-list-ul list-group list-group-flush">
+    <ul data-testid="search-result-list" className="page-list-ul list-group list-group-flush">
       { (injectedPage ?? pages).map((page, i) => {
       { (injectedPage ?? pages).map((page, i) => {
         return (
         return (
           <PageListItemL
           <PageListItemL

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

@@ -29,7 +29,7 @@ const SortControl: FC <Props> = (props: Props) => {
 
 
   return (
   return (
     <>
     <>
-      <div className="input-group">
+      <div className="input-group flex-nowrap">
         <div className="input-group-prepend">
         <div className="input-group-prepend">
           <div className="input-group-text border text-muted" id="btnGroupAddon">
           <div className="input-group-text border text-muted" id="btnGroupAddon">
             {renderOrderIcon()}
             {renderOrderIcon()}

+ 30 - 16
packages/app/src/components/SearchPage2/SearchPageBase.tsx

@@ -1,6 +1,7 @@
 import React, {
 import React, {
   forwardRef, ForwardRefRenderFunction, useEffect, useImperativeHandle, useRef, useState,
   forwardRef, ForwardRefRenderFunction, useEffect, useImperativeHandle, useRef, useState,
 } from 'react';
 } from 'react';
+import { useTranslation } from 'react-i18next';
 import { ISelectableAll } from '~/client/interfaces/selectable-all';
 import { ISelectableAll } from '~/client/interfaces/selectable-all';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import { IPageWithMeta } from '~/interfaces/page';
 import { IPageWithMeta } from '~/interfaces/page';
@@ -23,6 +24,8 @@ type Props = {
 }
 }
 
 
 const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll, Props> = (props:Props, ref) => {
 const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll, Props> = (props:Props, ref) => {
+  const { t } = useTranslation();
+
   const {
   const {
     appContainer,
     appContainer,
     pages,
     pages,
@@ -103,11 +106,9 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll, Props> =
     }
     }
   }, [onSelectedPagesByCheckboxesChanged, pages, selectedPageIdsByCheckboxes]);
   }, [onSelectedPagesByCheckboxesChanged, pages, selectedPageIdsByCheckboxes]);
 
 
-  const isLoading = pages == null;
-
   return (
   return (
     <div className="content-main">
     <div className="content-main">
-      <div className="search-result d-flex" id="search-result">
+      <div className="search-result-base d-flex" data-testid="search-result-base">
 
 
         <div className="mw-0 flex-grow-1 flex-basis-0 border boder-gray search-result-list" id="search-result-list">
         <div className="mw-0 flex-grow-1 flex-basis-0 border boder-gray search-result-list" id="search-result-list">
 
 
@@ -115,27 +116,40 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll, Props> =
 
 
           <div className="search-result-list-scroll">
           <div className="search-result-list-scroll">
 
 
-            { isLoading && (
+            {/* Loading */}
+            { pages == null && (
               <div className="mw-0 flex-grow-1 flex-basis-0 m-5 text-muted text-center">
               <div className="mw-0 flex-grow-1 flex-basis-0 m-5 text-muted text-center">
                 <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
                 <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
               </div>
               </div>
             ) }
             ) }
 
 
-            { !isLoading && (
+            {/* Loaded */}
+            { pages != null && (
               <>
               <>
-                <div className="my-3 px-md-4">
+                <div className="my-3 px-md-4 px-3">
                   {searchResultListHead}
                   {searchResultListHead}
                 </div>
                 </div>
-                <div className="page-list px-md-4">
-                  <SearchResultList
-                    ref={searchResultListRef}
-                    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-                    pages={pages!}
-                    selectedPageId={selectedPageWithMeta?.pageData._id}
-                    onPageSelected={page => setSelectedPageWithMeta(page)}
-                    onCheckboxChanged={checkboxChangedHandler}
-                  />
-                </div>
+
+                {/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */}
+                { pages.length === 0 && (
+                  <div className="d-flex justify-content-center h2 text-muted my-5">
+                    0 {t('search_result.page_number_unit')}
+                  </div>
+                ) }
+
+                {/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */}
+                { pages.length > 0 && (
+                  <div className="page-list px-md-4">
+                    <SearchResultList
+                      ref={searchResultListRef}
+                      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+                      pages={pages!}
+                      selectedPageId={selectedPageWithMeta?.pageData._id}
+                      onPageSelected={page => setSelectedPageWithMeta(page)}
+                      onCheckboxChanged={checkboxChangedHandler}
+                    />
+                  </div>
+                ) }
                 <div className="my-4 d-flex justify-content-center">
                 <div className="my-4 d-flex justify-content-center">
                   {searchPager}
                   {searchPager}
                 </div>
                 </div>

+ 5 - 4
packages/app/src/components/SearchTypeahead.tsx

@@ -39,6 +39,7 @@ type Props = TypeaheadProps & {
   onSubmit?: (input: string) => void,
   onSubmit?: (input: string) => void,
   inputName?: string,
   inputName?: string,
   keywordOnInit?: string,
   keywordOnInit?: string,
+  disableIncrementalSearch?: boolean,
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   helpElement?: any,
   helpElement?: any,
 };
 };
@@ -56,7 +57,7 @@ type TypeaheadInstanceFactory = {
 const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Props, ref) => {
 const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Props, ref) => {
   const {
   const {
     onSearchSuccess, onSearchError, onInputChange, onSubmit,
     onSearchSuccess, onSearchError, onInputChange, onSubmit,
-    emptyLabel, helpElement, keywordOnInit,
+    emptyLabel, helpElement, keywordOnInit, disableIncrementalSearch,
   } = props;
   } = props;
 
 
   // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
   // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -126,7 +127,7 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
   }, [onSearchError]);
   }, [onSearchError]);
 
 
   const search = useCallback(async(keyword: string) => {
   const search = useCallback(async(keyword: string) => {
-    if (keyword === '') {
+    if (disableIncrementalSearch || keyword === '') {
       return;
       return;
     }
     }
 
 
@@ -143,7 +144,7 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
       setLoading(false);
       setLoading(false);
     }
     }
 
 
-  }, [searchErrorHandler, searchSuccessHandler]);
+  }, [disableIncrementalSearch, searchErrorHandler, searchSuccessHandler]);
 
 
   const inputChangeHandler = useCallback((text: string) => {
   const inputChangeHandler = useCallback((text: string) => {
     setInput(text);
     setInput(text);
@@ -211,7 +212,7 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
         minLength={0}
         minLength={0}
         options={pages} // Search result (Some page names)
         options={pages} // Search result (Some page names)
         promptText={props.helpElement}
         promptText={props.helpElement}
-        emptyLabel={getEmptyLabel()}
+        emptyLabel={disableIncrementalSearch ? null : getEmptyLabel()}
         align="left"
         align="left"
         onSearch={search}
         onSearch={search}
         onInputChange={inputChangeHandler}
         onInputChange={inputChangeHandler}

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

@@ -21,7 +21,7 @@ const TrashPageList = (props) => {
   }, [t]);
   }, [t]);
 
 
   return (
   return (
-    <div className="mt-5 d-edit-none">
+    <div data-testid="trash-page-list" className="mt-5 d-edit-none">
       <CustomNavAndContents navTabMapping={navTabMapping} />
       <CustomNavAndContents navTabMapping={navTabMapping} />
     </div>
     </div>
   );
   );

+ 2 - 1
packages/app/src/server/crowi/index.js

@@ -398,11 +398,12 @@ Crowi.prototype.autoInstall = function() {
     admin: true,
     admin: true,
   };
   };
   const globalLang = this.configManager.getConfig('crowi', 'autoInstall:globalLang');
   const globalLang = this.configManager.getConfig('crowi', 'autoInstall:globalLang');
+  const serverDate = this.configManager.getConfig('crowi', 'autoInstall:serverDate');
 
 
   const installerService = new InstallerService(this);
   const installerService = new InstallerService(this);
 
 
   try {
   try {
-    installerService.install(firstAdminUserToSave, globalLang ?? 'en_US');
+    installerService.install(firstAdminUserToSave, globalLang ?? 'en_US', serverDate);
   }
   }
   catch (err) {
   catch (err) {
     logger.warn('Automatic installation failed.', err);
     logger.warn('Automatic installation failed.', err);

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

@@ -225,7 +225,7 @@ schema.statics.getParentAndFillAncestors = async function(path: string): Promise
   /*
   /*
    * Fill parents if parent is null
    * Fill parents if parent is null
    */
    */
-  const ancestorPaths = collectAncestorPaths(path, [path]); // paths of parents need to be created
+  const ancestorPaths = collectAncestorPaths(path); // paths of parents need to be created
 
 
   // just create ancestors with empty pages
   // just create ancestors with empty pages
   await this.createEmptyPagesByPaths(ancestorPaths);
   await this.createEmptyPagesByPaths(ancestorPaths);

+ 4 - 3
packages/app/src/server/routes/search.js

@@ -113,7 +113,7 @@ module.exports = function(crowi, app) {
   api.search = async function(req, res) {
   api.search = async function(req, res) {
     const user = req.user;
     const user = req.user;
     const {
     const {
-      q: keyword = null, type = null, sort = null, order = null,
+      q = null, type = null, sort = null, order = null,
     } = req.query;
     } = req.query;
     let paginateOpts;
     let paginateOpts;
 
 
@@ -124,8 +124,8 @@ module.exports = function(crowi, app) {
       res.json(ApiResponse.error(e));
       res.json(ApiResponse.error(e));
     }
     }
 
 
-    if (keyword === null || keyword === '') {
-      return res.json(ApiResponse.error('keyword should not empty.'));
+    if (q === null || q === '') {
+      return res.json(ApiResponse.error('The param "q" should not empty.'));
     }
     }
 
 
     const { searchService } = crowi;
     const { searchService } = crowi;
@@ -146,6 +146,7 @@ module.exports = function(crowi, app) {
     let searchResult;
     let searchResult;
     let delegatorName;
     let delegatorName;
     try {
     try {
+      const keyword = decodeURIComponent(q);
       [searchResult, delegatorName] = await searchService.searchKeyword(keyword, user, userGroups, searchOpts);
       [searchResult, delegatorName] = await searchService.searchKeyword(keyword, user, userGroups, searchOpts);
     }
     }
     catch (err) {
     catch (err) {

+ 11 - 2
packages/app/src/server/service/config-loader.ts

@@ -1,3 +1,5 @@
+import { parseISO } from 'date-fns';
+
 import { envUtils } from '@growi/core';
 import { envUtils } from '@growi/core';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -9,7 +11,7 @@ import ConfigModel, {
 
 
 const logger = loggerFactory('growi:service:ConfigLoader');
 const logger = loggerFactory('growi:service:ConfigLoader');
 
 
-enum ValueType { NUMBER, STRING, BOOLEAN }
+enum ValueType { NUMBER, STRING, BOOLEAN, DATE }
 
 
 interface ValueParser<T> {
 interface ValueParser<T> {
   parse(value: string): T;
   parse(value: string): T;
@@ -26,10 +28,11 @@ type EnumDictionary<T extends string | symbol | number, U> = {
   [K in T]: U;
   [K in T]: U;
 };
 };
 
 
-const parserDictionary: EnumDictionary<ValueType, ValueParser<number | string | boolean>> = {
+const parserDictionary: EnumDictionary<ValueType, ValueParser<number | string | boolean | Date>> = {
   [ValueType.NUMBER]:  { parse: (v: string) => { return parseInt(v, 10) } },
   [ValueType.NUMBER]:  { parse: (v: string) => { return parseInt(v, 10) } },
   [ValueType.STRING]:  { parse: (v: string) => { return v } },
   [ValueType.STRING]:  { parse: (v: string) => { return v } },
   [ValueType.BOOLEAN]: { parse: (v: string) => { return envUtils.toBoolean(v) } },
   [ValueType.BOOLEAN]: { parse: (v: string) => { return envUtils.toBoolean(v) } },
+  [ValueType.DATE]:    { parse: (v: string) => { return parseISO(v) } },
 };
 };
 
 
 /**
 /**
@@ -208,6 +211,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.STRING,
     type:    ValueType.STRING,
     default: null,
     default: null,
   },
   },
+  AUTO_INSTALL_SERVER_DATE: {
+    ns:      'crowi',
+    key:     'autoInstall:serverDate',
+    type:    ValueType.DATE,
+    default: null,
+  },
   S2SMSG_PUBSUB_SERVER_TYPE: {
   S2SMSG_PUBSUB_SERVER_TYPE: {
     ns:      'crowi',
     ns:      'crowi',
     key:     's2sMessagingPubsub:serverType',
     key:     's2sMessagingPubsub:serverType',

+ 27 - 5
packages/app/src/server/service/installer.ts

@@ -34,7 +34,12 @@ export class InstallerService {
       return;
       return;
     }
     }
 
 
-    await searchService.rebuildIndex();
+    try {
+      await searchService.rebuildIndex();
+    }
+    catch (err) {
+      logger.error('Rebuild index failed', err);
+    }
   }
   }
 
 
   private async createPage(filePath, pagePath, owner): Promise<IPage|undefined> {
   private async createPage(filePath, pagePath, owner): Promise<IPage|undefined> {
@@ -53,7 +58,7 @@ export class InstallerService {
   }
   }
 
 
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  private async createInitialPages(owner, lang: Lang): Promise<any> {
+  private async createInitialPages(owner, lang: Lang, initialPagesCreatedAt?: Date): Promise<any> {
     const { localeDir } = this.crowi;
     const { localeDir } = this.crowi;
     // create /Sandbox/*
     // create /Sandbox/*
     /*
     /*
@@ -66,6 +71,19 @@ export class InstallerService {
       this.createPage(path.join(localeDir, lang, 'sandbox-math.md'), '/Sandbox/Math', owner),
       this.createPage(path.join(localeDir, lang, 'sandbox-math.md'), '/Sandbox/Math', owner),
     ]);
     ]);
 
 
+    // update createdAt and updatedAt fields of all pages
+    if (initialPagesCreatedAt != null) {
+      try {
+        // TODO typescriptize models/user.js and remove eslint-disable-next-line
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+        const Page = mongoose.model('Page') as any;
+        await Page.updateMany({}, { createdAt: initialPagesCreatedAt, updatedAt: initialPagesCreatedAt });
+      }
+      catch (err) {
+        logger.error('Failed to update createdAt', err);
+      }
+    }
+
     try {
     try {
       await this.initSearchIndex();
       await this.initSearchIndex();
     }
     }
@@ -85,7 +103,7 @@ export class InstallerService {
     return configManager.updateConfigsInTheSameNamespace('crowi', initialConfig, true);
     return configManager.updateConfigsInTheSameNamespace('crowi', initialConfig, true);
   }
   }
 
 
-  async install(firstAdminUserToSave: IUser, globalLang: Lang): Promise<IUser> {
+  async install(firstAdminUserToSave: IUser, globalLang: Lang, initialPagesCreatedAt?: Date): Promise<IUser> {
     await this.initDB(globalLang);
     await this.initDB(globalLang);
 
 
     // TODO typescriptize models/user.js and remove eslint-disable-next-line
     // TODO typescriptize models/user.js and remove eslint-disable-next-line
@@ -94,7 +112,11 @@ export class InstallerService {
     const Page = mongoose.model('Page') as any;
     const Page = mongoose.model('Page') as any;
 
 
     // create portal page for '/' before creating admin user
     // create portal page for '/' before creating admin user
-    await this.createPage(path.join(this.crowi.localeDir, globalLang, 'welcome.md'), '/', { _id: '000000000000000000000000' }); // use 0 as a mock user id
+    await this.createPage(
+      path.join(this.crowi.localeDir, globalLang, 'welcome.md'),
+      '/',
+      { _id: '000000000000000000000000' }, // use 0 as a mock user id
+    );
 
 
     // create first admin user
     // create first admin user
     // TODO: with transaction
     // TODO: with transaction
@@ -120,7 +142,7 @@ export class InstallerService {
     await Promise.all([rootPage.save(), rootRevision.save()]);
     await Promise.all([rootPage.save(), rootRevision.save()]);
 
 
     // create initial pages
     // create initial pages
-    await this.createInitialPages(adminUser, globalLang);
+    await this.createInitialPages(adminUser, globalLang, initialPagesCreatedAt);
 
 
     return adminUser;
     return adminUser;
   }
   }

+ 32 - 11
packages/app/src/server/service/search-delegator/elasticsearch.ts

@@ -149,10 +149,16 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
     };
     };
   }
   }
 
 
-  async init() {
+  async init(): Promise<void> {
     const normalizeIndices = await this.normalizeIndices();
     const normalizeIndices = await this.normalizeIndices();
     if (this.isElasticsearchReindexOnBoot) {
     if (this.isElasticsearchReindexOnBoot) {
-      return this.rebuildIndex();
+      try {
+        await this.rebuildIndex();
+      }
+      catch (err) {
+        logger.error('Rebuild index on boot failed', err);
+      }
+      return;
     }
     }
     return normalizeIndices;
     return normalizeIndices;
   }
   }
@@ -284,7 +290,8 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
       await this.addAllPages();
       await this.addAllPages();
     }
     }
     catch (error) {
     catch (error) {
-      logger.warn('An error occured while \'rebuildIndex\', normalize indices anyway.');
+      logger.error('An error occured while \'rebuildIndex\'.', error);
+      logger.error('error.meta.body', error?.meta?.body);
 
 
       const socket = this.socketIoService.getAdminSocket();
       const socket = this.socketIoService.getAdminSocket();
       socket.emit('rebuildingFailed', { error: error.message });
       socket.emit('rebuildingFailed', { error: error.message });
@@ -292,6 +299,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
       throw error;
       throw error;
     }
     }
     finally {
     finally {
+      logger.warn('Normalize indices anyway.');
       await this.normalizeIndices();
       await this.normalizeIndices();
     }
     }
 
 
@@ -325,8 +333,18 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
   }
   }
 
 
   async createIndex(index) {
   async createIndex(index) {
-    const body = this.isElasticsearchV6 ? require('^/resource/search/mappings-es6.json') : require('^/resource/search/mappings-es7.json');
-    return this.client.indices.create({ index, body });
+    let mappings = this.isElasticsearchV6
+      ? require('^/resource/search/mappings-es6.json')
+      : require('^/resource/search/mappings-es7.json');
+
+    if (process.env.CI) {
+      mappings = require('^/resource/search/mappings-es6-for-ci.json');
+    }
+
+    return this.client.indices.create({
+      index,
+      body: mappings,
+    });
   }
   }
 
 
   /**
   /**
@@ -614,22 +632,25 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
    * }
    * }
    */
    */
   async searchKeyword(query): Promise<IFormattedSearchResult> {
   async searchKeyword(query): Promise<IFormattedSearchResult> {
+
     // for debug
     // for debug
     if (process.env.NODE_ENV === 'development') {
     if (process.env.NODE_ENV === 'development') {
+      logger.debug('query: ', { query });
+
       const { body: result } = await this.client.indices.validateQuery({
       const { body: result } = await this.client.indices.validateQuery({
+        index: query.index,
+        type: query.type,
         explain: true,
         explain: true,
         body: {
         body: {
           query: query.body.query,
           query: query.body.query,
         },
         },
       });
       });
-      logger.debug('ES returns explanations: ', result.explanations);
+      // for debug
+      logger.debug('ES result: ', result);
     }
     }
 
 
     const { body: result } = await this.client.search(query);
     const { body: result } = await this.client.search(query);
 
 
-    // for debug
-    logger.debug('ES result: ', result);
-
     const totalValue = this.isElasticsearchV6 ? result.hits.total : result.hits.total.value;
     const totalValue = this.isElasticsearchV6 ? result.hits.total : result.hits.total.value;
 
 
     return {
     return {
@@ -665,9 +686,9 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
     // eslint-disable-next-line prefer-const
     // eslint-disable-next-line prefer-const
     let query = {
     let query = {
       index: this.aliasName,
       index: this.aliasName,
+      _source: fields,
       body: {
       body: {
         query: {}, // query
         query: {}, // query
-        _source: fields,
       },
       },
     };
     };
 
 
@@ -687,7 +708,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
     // default sort order is score descending
     // default sort order is score descending
     const sort = ES_SORT_AXIS[sortAxis] || ES_SORT_AXIS[RELATION_SCORE];
     const sort = ES_SORT_AXIS[sortAxis] || ES_SORT_AXIS[RELATION_SCORE];
     const order = ES_SORT_ORDER[sortOrder] || ES_SORT_ORDER[DESC];
     const order = ES_SORT_ORDER[sortOrder] || ES_SORT_ORDER[DESC];
-    query.body.sort = { [sort]: { order } };
+    query.sort = { [sort]: { order } };
   }
   }
 
 
   convertSortQuery(sortAxis) {
   convertSortQuery(sortAxis) {

+ 1 - 1
packages/app/src/server/views/layout-growi/page_list.html

@@ -15,7 +15,7 @@
 {% block content_main_after %}
 {% block content_main_after %}
   {% if isTrashPage(path) %}
   {% if isTrashPage(path) %}
     <div class="grw-container-convertible">
     <div class="grw-container-convertible">
-      <div id="trash-page-list"></div>
+      <div id="trash-page-list-container"></div>
     </div>
     </div>
   {% endif %}
   {% endif %}
 {% endblock %}
 {% endblock %}

+ 1 - 1
packages/app/src/server/views/tags.html

@@ -9,7 +9,7 @@
 <div class="grw-container-convertible">
 <div class="grw-container-convertible">
   <div class="row">
   <div class="row">
     <div id="main" class="main mt-3 col-md-12 tags-page">
     <div id="main" class="main mt-3 col-md-12 tags-page">
-      <div class="" id="tags-page"></div>
+      <div class="" id="tags-page" data-testid="tags-page"></div>
     </div>
     </div>
   </div>
   </div>
 </div><!-- /.container-fluid -->
 </div><!-- /.container-fluid -->

+ 2 - 3
packages/app/src/styles/_search.scss

@@ -193,7 +193,7 @@
       }
       }
     }
     }
 
 
-    .search-result-page {
+    .search-result-content {
       height: calc(100vh - ($grw-navbar-height + $grw-navbar-border-width));
       height: calc(100vh - ($grw-navbar-height + $grw-navbar-border-width));
 
 
       > h2 {
       > h2 {
@@ -206,13 +206,12 @@
         margin-top: 0;
         margin-top: 0;
       }
       }
 
 
-      .search-result-page-content {
+      .search-result-content-body-container {
         overflow-y: auto;
         overflow-y: auto;
 
 
         .wiki {
         .wiki {
           padding: 16px;
           padding: 16px;
           font-size: 13px;
           font-size: 13px;
-          border: solid 1px $gray-300;
         }
         }
       }
       }
     }
     }

+ 9 - 4
packages/app/src/styles/_subnav.scss

@@ -137,6 +137,15 @@
   }
   }
 }
 }
 
 
+/*
+ * shadow
+ */
+.grw-subnav-append-shadow-container {
+  .grw-subnav {
+    box-shadow: 0px 0px 6px 3px rgba(black, 0.15);
+  }
+}
+
 /*
 /*
  * Fixed ver
  * Fixed ver
  */
  */
@@ -145,10 +154,6 @@ $easeInOutCubic: cubic-bezier(0.65, 0, 0.35, 1);
 .grw-subnav-fixed-container {
 .grw-subnav-fixed-container {
   top: $grw-navbar-border-width;
   top: $grw-navbar-border-width;
   z-index: $zindex-sticky - 5;
   z-index: $zindex-sticky - 5;
-
-  .grw-subnav {
-    box-shadow: 0px 0px 6px 3px rgba(black, 0.15);
-  }
 }
 }
 
 
 /*
 /*

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

@@ -638,7 +638,7 @@ body.pathname-sidebar {
 /*
 /*
  * GROWI search result
  * GROWI search result
  */
  */
-.search-result {
+.search-result-base {
   .grw-search-page-nav {
   .grw-search-page-nav {
     background-color: $bgcolor-subnav;
     background-color: $bgcolor-subnav;
   }
   }

+ 13 - 0
packages/app/test/cypress/integration/2-basic-features/access-to-admin-page.spec.ts

@@ -37,66 +37,79 @@ context('Access to Admin page', () => {
 
 
   it('/admin is successfully loaded', () => {
   it('/admin is successfully loaded', () => {
     cy.visit('/admin');
     cy.visit('/admin');
+    cy.getByTestid('admin-home').should('be.visible');
     cy.screenshot(`${ssPrefix}-admin`, { capture: 'viewport' });
     cy.screenshot(`${ssPrefix}-admin`, { capture: 'viewport' });
   });
   });
 
 
   it('/admin/app is successfully loaded', () => {
   it('/admin/app is successfully loaded', () => {
     cy.visit('/admin/app');
     cy.visit('/admin/app');
+    cy.getByTestid('admin-app-settings').should('be.visible');
     cy.screenshot(`${ssPrefix}-admin-app`, { capture: 'viewport' });
     cy.screenshot(`${ssPrefix}-admin-app`, { capture: 'viewport' });
   });
   });
 
 
   it('/admin/security is successfully loaded', () => {
   it('/admin/security is successfully loaded', () => {
     cy.visit('/admin/security');
     cy.visit('/admin/security');
+    cy.getByTestid('admin-security').should('be.visible');
     cy.screenshot(`${ssPrefix}-admin-security`, { capture: 'viewport' });
     cy.screenshot(`${ssPrefix}-admin-security`, { capture: 'viewport' });
   });
   });
 
 
   it('/admin/markdown is successfully loaded', () => {
   it('/admin/markdown is successfully loaded', () => {
     cy.visit('/admin/markdown');
     cy.visit('/admin/markdown');
+    cy.getByTestid('admin-markdown').should('be.visible');
     cy.screenshot(`${ssPrefix}-admin-markdown`, { capture: 'viewport' });
     cy.screenshot(`${ssPrefix}-admin-markdown`, { capture: 'viewport' });
   });
   });
 
 
   it('/admin/customize is successfully loaded', () => {
   it('/admin/customize is successfully loaded', () => {
     cy.visit('/admin/customize');
     cy.visit('/admin/customize');
+    cy.getByTestid('admin-customize').should('be.visible');
     cy.screenshot(`${ssPrefix}-admin-customize`, { capture: 'viewport' });
     cy.screenshot(`${ssPrefix}-admin-customize`, { capture: 'viewport' });
   });
   });
 
 
   it('/admin/importer is successfully loaded', () => {
   it('/admin/importer is successfully loaded', () => {
     cy.visit('/admin/importer');
     cy.visit('/admin/importer');
+    cy.getByTestid('admin-import-data').should('be.visible');
     cy.screenshot(`${ssPrefix}-admin-importer`, { capture: 'viewport' });
     cy.screenshot(`${ssPrefix}-admin-importer`, { capture: 'viewport' });
   });
   });
 
 
   it('/admin/export is successfully loaded', () => {
   it('/admin/export is successfully loaded', () => {
     cy.visit('/admin/export');
     cy.visit('/admin/export');
+    cy.getByTestid('admin-export-archive-data').should('be.visible');
     cy.screenshot(`${ssPrefix}-admin-export`, { capture: 'viewport' });
     cy.screenshot(`${ssPrefix}-admin-export`, { capture: 'viewport' });
   });
   });
 
 
   it('/admin/notification is successfully loaded', () => {
   it('/admin/notification is successfully loaded', () => {
     cy.visit('/admin/notification');
     cy.visit('/admin/notification');
+    cy.getByTestid('admin-notification').should('be.visible');
     cy.screenshot(`${ssPrefix}-admin-notification`, { capture: 'viewport' });
     cy.screenshot(`${ssPrefix}-admin-notification`, { capture: 'viewport' });
   });
   });
 
 
   it('/admin/slack-integration is successfully loaded', () => {
   it('/admin/slack-integration is successfully loaded', () => {
     cy.visit('/admin/slack-integration');
     cy.visit('/admin/slack-integration');
+    cy.getByTestid('admin-slack-integration').should('be.visible');
     cy.screenshot(`${ssPrefix}-admin-slack-integration`, { capture: 'viewport' });
     cy.screenshot(`${ssPrefix}-admin-slack-integration`, { capture: 'viewport' });
   });
   });
 
 
   it('/admin/slack-integration-legacy is successfully loaded', () => {
   it('/admin/slack-integration-legacy is successfully loaded', () => {
     cy.visit('/admin/slack-integration-legacy');
     cy.visit('/admin/slack-integration-legacy');
+    cy.getByTestid('admin-slack-integration-legacy').should('be.visible');
     cy.screenshot(`${ssPrefix}-admin-slack-integration-legacy`, { capture: 'viewport' });
     cy.screenshot(`${ssPrefix}-admin-slack-integration-legacy`, { capture: 'viewport' });
   });
   });
 
 
   it('/admin/users is successfully loaded', () => {
   it('/admin/users is successfully loaded', () => {
     cy.visit('/admin/users');
     cy.visit('/admin/users');
+    cy.getByTestid('admin-users').should('be.visible');
     cy.screenshot(`${ssPrefix}-admin-users`, { capture: 'viewport' });
     cy.screenshot(`${ssPrefix}-admin-users`, { capture: 'viewport' });
   });
   });
 
 
   it('/admin/user-groups is successfully loaded', () => {
   it('/admin/user-groups is successfully loaded', () => {
     cy.visit('/admin/user-groups');
     cy.visit('/admin/user-groups');
+    cy.getByTestid('admin-user-groups').should('be.visible');
     cy.screenshot(`${ssPrefix}-admin-user-groups`, { capture: 'viewport' });
     cy.screenshot(`${ssPrefix}-admin-user-groups`, { capture: 'viewport' });
   });
   });
 
 
   it('/admin/search is successfully loaded', () => {
   it('/admin/search is successfully loaded', () => {
     cy.visit('/admin/search');
     cy.visit('/admin/search');
+    cy.getByTestid('admin-full-text-search').should('be.visible');
     cy.screenshot(`${ssPrefix}-admin-search`, { capture: 'viewport' });
     cy.screenshot(`${ssPrefix}-admin-search`, { capture: 'viewport' });
   });
   });
 
 

+ 5 - 0
packages/app/test/cypress/integration/2-basic-features/access-to-me-page.spec.ts

@@ -25,4 +25,9 @@ context('Access to page', () => {
     cy.screenshot(`${ssPrefix}-me`, { capture: 'viewport' });
     cy.screenshot(`${ssPrefix}-me`, { capture: 'viewport' });
   });
   });
 
 
+  it('Draft page is successfully shown', () => {
+    cy.visit('/me/drafts');
+    cy.screenshot(`${ssPrefix}-draft-page`, { capture: 'viewport' });
+  });
+
 });
 });

+ 0 - 15
packages/app/test/cypress/integration/2-basic-features/access-to-page.spec.ts

@@ -30,11 +30,6 @@ context('Access to page', () => {
     cy.screenshot(`${ssPrefix}-sandbox-headers`, { capture: 'viewport' });
     cy.screenshot(`${ssPrefix}-sandbox-headers`, { capture: 'viewport' });
   });
   });
 
 
-  it('/trash is successfully loaded', () => {
-    cy.visit('/trash', {  });
-    cy.screenshot(`${ssPrefix}-trash`, { capture: 'viewport' });
-  });
-
   it('/Sandbox/Math is successfully loaded', () => {
   it('/Sandbox/Math is successfully loaded', () => {
     cy.visit('/Sandbox/Math');
     cy.visit('/Sandbox/Math');
     cy.screenshot(`${ssPrefix}-sandbox-math`, { capture: 'viewport' });
     cy.screenshot(`${ssPrefix}-sandbox-math`, { capture: 'viewport' });
@@ -50,14 +45,4 @@ context('Access to page', () => {
     cy.screenshot(`${ssPrefix}-user-admin`, { capture: 'viewport' });
     cy.screenshot(`${ssPrefix}-user-admin`, { capture: 'viewport' });
   });
   });
 
 
-  it('Draft page is successfully shown', () => {
-    cy.visit('/me/drafts');
-    cy.screenshot(`${ssPrefix}-draft-page`, { capture: 'viewport' });
-  });
-  
-  it('/tags is successfully loaded', () => {
-    cy.visit('/tags');
-    cy.screenshot(`${ssPrefix}-tags`, { capture: 'viewport' });
-  });
-  
 });
 });

+ 35 - 0
packages/app/test/cypress/integration/2-basic-features/access-to-special-page.spec.ts

@@ -0,0 +1,35 @@
+
+context('Access to special pages', () => {
+  const ssPrefix = 'access-to-special-pages-';
+
+  let connectSid: string | undefined;
+
+  before(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+    cy.getCookie('connect.sid').then(cookie => {
+      connectSid = cookie?.value;
+    });
+  });
+
+  beforeEach(() => {
+    if (connectSid != null) {
+      cy.setCookie('connect.sid', connectSid);
+    }
+  });
+
+  it('/trash is successfully loaded', () => {
+    cy.visit('/trash', {  });
+    cy.getByTestid('trash-page-list').should('be.visible');
+    cy.screenshot(`${ssPrefix}-trash`, { capture: 'viewport' });
+  });
+
+  it('/tags is successfully loaded', () => {
+    cy.visit('/tags');
+    cy.getByTestid('tags-page').should('be.visible');
+    cy.screenshot(`${ssPrefix}-tags`, { capture: 'viewport' });
+  });
+
+});

+ 37 - 0
packages/app/test/cypress/integration/2-basic-features/open-page-create-modal.spec.ts

@@ -0,0 +1,37 @@
+context('Open PageCreateModal', () => {
+
+  const ssPrefix = 'open-page-create-modal-';
+
+  let connectSid: string | undefined;
+
+  before(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+    cy.getCookie('connect.sid').then(cookie => {
+      connectSid = cookie?.value;
+    });
+  });
+
+  beforeEach(() => {
+    if (connectSid != null) {
+      cy.setCookie('connect.sid', connectSid);
+      cy.visit('/');
+    }
+  });
+
+  it("PageCreateModal is shown successfully", () => {
+    cy.getByTestid('newPageBtn').click();
+
+    cy.getByTestid('page-create-modal').should('be.visible');
+    cy.screenshot(`${ssPrefix}-open`,{ capture: 'viewport' });
+
+    cy.getByTestid('row-create-page-under-below').find('input.form-control').clear().type('/new-page');
+    cy.getByTestid('btn-create-page-under-below').click();
+
+    cy.getByTestid('page-editor').should('be.visible');
+    cy.screenshot(`${ssPrefix}-create-clicked`, {capture: 'viewport'});
+  });
+
+});

+ 9 - 4
packages/app/test/cypress/integration/3-search/access-to-result-page-directly.spec.ts

@@ -20,14 +20,19 @@ context('Access to search result page directly', () => {
   });
   });
 
 
   it('/_search with "q" param is successfully loaded', () => {
   it('/_search with "q" param is successfully loaded', () => {
-    cy.visit('/_search', { qs: { q: 'sandbox headers blockquotes' } });
-    // eslint-disable-next-line cypress/no-unnecessary-waiting
-    cy.wait(1000);
+    cy.visit('/_search', { qs: { q: 'bootstrap4' } });
+
+    cy.getByTestid('search-result-list').should('be.visible');
+    cy.getByTestid('search-result-content').should('be.visible');
+
     cy.screenshot(`${ssPrefix}-with-q`, { capture: 'viewport' });
     cy.screenshot(`${ssPrefix}-with-q`, { capture: 'viewport' });
   });
   });
 
 
   it('checkboxes behaviors', () => {
   it('checkboxes behaviors', () => {
-    cy.visit('/_search', { qs: { q: 'sandbox headers blockquotes' } });
+    cy.visit('/_search', { qs: { q: 'bootstrap4' } });
+
+    cy.getByTestid('search-result-base').should('be.visible');
+    cy.getByTestid('search-result-list').should('be.visible');
 
 
     cy.getByTestid('cb-select').first().click({force: true});
     cy.getByTestid('cb-select').first().click({force: true});
     cy.screenshot(`${ssPrefix}-the-first-checkbox-on`, { capture: 'viewport' });
     cy.screenshot(`${ssPrefix}-the-first-checkbox-on`, { capture: 'viewport' });