ソースを参照

Merge branch 'master' into imprv/88317-switch-delete-modal-ui

cao 4 年 前
コミット
9f6b448402
77 ファイル変更748 行追加396 行削除
  1. 13 10
      .github/workflows/reusable-app-prod.yml
  2. 1 0
      packages/app/.env.development
  3. 3 1
      packages/app/config/ci/.env.local.for-auto-install
  4. 0 3
      packages/app/config/ci/.env.local.for-ci
  5. 1 0
      packages/app/resource/locales/en_US/translation.json
  6. 1 0
      packages/app/resource/locales/ja_JP/translation.json
  7. 1 0
      packages/app/resource/locales/zh_CN/translation.json
  8. 123 0
      packages/app/resource/search/mappings-es6-for-ci.json
  9. 36 15
      packages/app/src/client/admin.jsx
  10. 1 1
      packages/app/src/client/app.jsx
  11. 12 0
      packages/app/src/client/services/ContextExtractor.tsx
  12. 2 2
      packages/app/src/components/Admin/AdminHome/AdminHome.jsx
  13. 2 2
      packages/app/src/components/Admin/App/AppSettingsPageContents.jsx
  14. 2 2
      packages/app/src/components/Admin/Customize/Customize.jsx
  15. 2 2
      packages/app/src/components/Admin/ExportArchiveDataPage.jsx
  16. 2 2
      packages/app/src/components/Admin/FullTextSearchManagement.jsx
  17. 2 2
      packages/app/src/components/Admin/ImportData/ImportDataPageContents.jsx
  18. 2 2
      packages/app/src/components/Admin/LegacySlackIntegration/LegacySlackIntegration.jsx
  19. 2 2
      packages/app/src/components/Admin/MarkdownSetting/MarkDownSettingContents.jsx
  20. 2 2
      packages/app/src/components/Admin/Notification/NotificationSetting.jsx
  21. 2 2
      packages/app/src/components/Admin/Security/SecurityManagementContents.jsx
  22. 2 2
      packages/app/src/components/Admin/SlackIntegration/SlackIntegration.jsx
  23. 0 3
      packages/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx
  24. 12 27
      packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx
  25. 37 36
      packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx
  26. 5 7
      packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  27. 84 32
      packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  28. 2 2
      packages/app/src/components/Admin/UserManagement.jsx
  29. 24 12
      packages/app/src/components/DescendantsPageList.tsx
  30. 1 1
      packages/app/src/components/DescendantsPageListModal.tsx
  31. 2 2
      packages/app/src/components/ForbiddenPage.tsx
  32. 0 1
      packages/app/src/components/IdenticalPathPage.tsx
  33. 1 0
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  34. 1 0
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  35. 7 1
      packages/app/src/components/Navbar/GrowiSubNavigation.tsx
  36. 6 1
      packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.jsx
  37. 3 1
      packages/app/src/components/Navbar/SubNavButtons.tsx
  38. 4 13
      packages/app/src/components/NotFoundPage.tsx
  39. 1 1
      packages/app/src/components/Page/DisplaySwitcher.tsx
  40. 14 3
      packages/app/src/components/PageCreateModal.jsx
  41. 8 3
      packages/app/src/components/PageList/PageListItemL.tsx
  42. 3 0
      packages/app/src/components/SearchForm.tsx
  43. 40 7
      packages/app/src/components/SearchPage.tsx
  44. 9 5
      packages/app/src/components/SearchPage/SearchControl.tsx
  45. 0 71
      packages/app/src/components/SearchPage/SearchPageForm.jsx
  46. 11 6
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  47. 2 6
      packages/app/src/components/SearchPage/SearchResultList.tsx
  48. 1 1
      packages/app/src/components/SearchPage/SortControl.tsx
  49. 31 17
      packages/app/src/components/SearchPage2/SearchPageBase.tsx
  50. 5 4
      packages/app/src/components/SearchTypeahead.tsx
  51. 5 2
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  52. 3 3
      packages/app/src/components/TrashPageList.jsx
  53. 1 0
      packages/app/src/interfaces/user-group-response.ts
  54. 2 1
      packages/app/src/server/crowi/index.js
  55. 1 1
      packages/app/src/server/middlewares/auto-reconnect-to-search.js
  56. 1 1
      packages/app/src/server/models/page.ts
  57. 0 2
      packages/app/src/server/routes/admin.js
  58. 1 1
      packages/app/src/server/routes/index.js
  59. 0 10
      packages/app/src/server/routes/page.js
  60. 4 3
      packages/app/src/server/routes/search.js
  61. 11 2
      packages/app/src/server/service/config-loader.ts
  62. 27 5
      packages/app/src/server/service/installer.ts
  63. 32 11
      packages/app/src/server/service/search-delegator/elasticsearch.ts
  64. 2 2
      packages/app/src/server/service/user-group.ts
  65. 2 2
      packages/app/src/server/views/layout-growi/page_list.html
  66. 1 1
      packages/app/src/server/views/tags.html
  67. 12 0
      packages/app/src/stores/context.tsx
  68. 4 7
      packages/app/src/stores/user-group.tsx
  69. 2 3
      packages/app/src/styles/_search.scss
  70. 9 4
      packages/app/src/styles/_subnav.scss
  71. 1 1
      packages/app/src/styles/theme/_apply-colors.scss
  72. 13 0
      packages/app/test/cypress/integration/2-basic-features/access-to-admin-page.spec.ts
  73. 5 0
      packages/app/test/cypress/integration/2-basic-features/access-to-me-page.spec.ts
  74. 0 15
      packages/app/test/cypress/integration/2-basic-features/access-to-page.spec.ts
  75. 35 0
      packages/app/test/cypress/integration/2-basic-features/access-to-special-page.spec.ts
  76. 37 0
      packages/app/test/cypress/integration/2-basic-features/open-page-create-modal.spec.ts
  77. 9 4
      packages/app/test/cypress/integration/3-search/access-to-result-page-directly.spec.ts

+ 13 - 10
.github/workflows/reusable-app-prod.yml

@@ -94,10 +94,12 @@ jobs:
         image: mongo:4.4
         ports:
         - 27017/tcp
-      mongodb36:
-        image: mongo:3.6
+      elasticsearch:
+        image: docker.elastic.co/elasticsearch/elasticsearch:6.8.23
         ports:
-        - 27017/tcp
+        - 9200/tcp
+        env:
+          discovery.type: single-node
 
     steps:
     - uses: actions/checkout@v2
@@ -151,13 +153,7 @@ jobs:
         yarn server:ci
       env:
         MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi
-    - name: yarn server:ci with MongoDB 3.6
-      working-directory: ./packages/app
-      run: |
-        cp config/ci/.env.local.for-ci .env.production.local
-        yarn server:ci
-      env:
-        MONGO_URI: mongodb://localhost:${{ job.services.mongodb36.ports['27017'] }}/growi
+        ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi
 
     - name: Slack Notification
       uses: weseek/ghaction-slack-notification@master
@@ -194,6 +190,12 @@ jobs:
         image: mongo:4.4
         ports:
         - 27017/tcp
+      elasticsearch:
+        image: docker.elastic.co/elasticsearch/elasticsearch:6.8.23
+        ports:
+        - 9200/tcp
+        env:
+          discovery.type: single-node
 
     steps:
     - uses: actions/checkout@v2
@@ -254,6 +256,7 @@ jobs:
         wait-on: 'http://localhost:3000'
       env:
         MONGO_URI: mongodb://mongodb:27017/growi-vrt
+        ELASTICSEARCH_URI: http://elasticsearch:9200/growi
 
     - name: Upload results
       if: always()

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

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

+ 3 - 1
packages/app/config/ci/.env.local.for-auto-install

@@ -4,4 +4,6 @@ AUTO_INSTALL_ADMIN_USERNAME=admin
 AUTO_INSTALL_ADMIN_NAME=Admin
 AUTO_INSTALL_ADMIN_EMAIL=admin@example.com
 AUTO_INSTALL_ADMIN_PASSWORD=adminadmin
-AUTO_INSTALL_GLOBAL_LANG=zh_CN
+AUTO_INSTALL_GLOBAL_LANG=en_US
+
+AUTO_INSTALL_SERVER_DATE=2022-01-01T00:00:00.0

+ 0 - 3
packages/app/config/ci/.env.local.for-ci

@@ -1,4 +1 @@
 FORMAT_NODE_LOG=true
-
-# disable Elasticsearch
-ELASTICSEARCH_URI=

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

@@ -125,6 +125,7 @@
   "User_Management": "User Management",
   "external_account_management": "External Account Management",
   "UserGroup": "UserGroup",
+  "ChildUserGroup": "ChildUserGroup",
   "UserGroup Management": "UserGroup Management",
   "Full Text Search Management": "Full Text Search Management",
   "Import Data": "Import Data",

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

@@ -125,6 +125,7 @@
   "User_Management": "ユーザー管理",
   "external_account_management": "外部アカウント管理",
   "UserGroup": "グループ",
+  "ChildUserGroup": "子グループ",
   "UserGroup Management": "グループ管理",
   "Full Text Search Management": "全文検索管理",
   "Import Data": "データインポート",

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

@@ -133,6 +133,7 @@
 	"User_Management": "用户管理",
 	"external_account_management": "外部账户管理",
   "UserGroup": "用户组",
+  "ChildUserGroup": "儿童用户组",
 	"UserGroup Management": "用户组管理",
 	"Full Text Search Management": "全文搜索管理",
 	"Import Data": "导入数据",

+ 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"
+        }
+      }
+    }
+  }
+}

+ 36 - 15
packages/app/src/client/admin.jsx

@@ -3,7 +3,10 @@ import ReactDOM from 'react-dom';
 import { Provider } from 'unstated';
 import { I18nextProvider } from 'react-i18next';
 
+import { SWRConfig } from 'swr';
+
 import loggerFactory from '~/utils/logger';
+import { swrGlobalConfiguration } from '~/utils/swr-utils';
 
 import ErrorBoundary from '../components/ErrorBoudary';
 
@@ -46,6 +49,8 @@ import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurit
 import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
 import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
 
+import ContextExtractor from '~/client/services/ContextExtractor';
+
 import { appContainer, componentMappings } from './base';
 
 const logger = loggerFactory('growi:admin');
@@ -109,22 +114,38 @@ Object.assign(componentMappings, {
   'admin-navigation': <AdminNavigation />,
 });
 
+const renderMainComponents = () => {
+  Object.keys(componentMappings).forEach((key) => {
+    const elem = document.getElementById(key);
+    if (elem) {
+      ReactDOM.render(
+        <I18nextProvider i18n={i18n}>
+          <ErrorBoundary>
+            <Provider inject={injectableContainers}>
+              {componentMappings[key]}
+            </Provider>
+          </ErrorBoundary>
+        </I18nextProvider>,
+        elem,
+      );
+    }
+  });
+};
 
-Object.keys(componentMappings).forEach((key) => {
-  const elem = document.getElementById(key);
-  if (elem) {
-    ReactDOM.render(
-      <I18nextProvider i18n={i18n}>
-        <ErrorBoundary>
-          <Provider inject={injectableContainers}>
-            {componentMappings[key]}
-          </Provider>
-        </ErrorBoundary>
-      </I18nextProvider>,
-      elem,
-    );
-  }
-});
+// extract context before rendering main components
+const elem = document.getElementById('growi-context-extractor');
+if (elem != null) {
+  ReactDOM.render(
+    <SWRConfig value={swrGlobalConfiguration}>
+      <ContextExtractor></ContextExtractor>
+    </SWRConfig>,
+    elem,
+    renderMainComponents,
+  );
+}
+else {
+  renderMainComponents();
+}
 
 const adminSecuritySettingElem = document.getElementById('admin-security-setting');
 if (adminSecuritySettingElem != null) {

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

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

+ 12 - 0
packages/app/src/client/services/ContextExtractor.tsx

@@ -7,6 +7,7 @@ import {
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
   useSlackChannels, useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath,
+  useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable,
 } from '../../stores/context';
 import {
   useIsDeviceSmallerThanMd, useIsDeviceSmallerThanLg,
@@ -30,6 +31,11 @@ const ContextExtractorOnce: FC = () => {
    */
   const currentUser = JSON.parse(document.getElementById('growi-current-user')?.textContent || jsonNull);
 
+  /*
+   * Settings from context-hydrate DOM
+   */
+  const configByContextHydrate = JSON.parse(document.getElementById('growi-context-hydrate')?.textContent || jsonNull);
+
   /*
    * UserUISettings from DOM
    */
@@ -91,6 +97,12 @@ const ContextExtractorOnce: FC = () => {
   useCurrentSidebarContents(userUISettings?.currentSidebarContents);
   useCurrentProductNavWidth(userUISettings?.currentProductNavWidth);
 
+  // hydrated config
+  useIsAclEnabled(configByContextHydrate.isAclEnabled);
+  useIsSearchServiceConfigured(configByContextHydrate.isSearchServiceConfigured);
+  useIsSearchServiceReachable(configByContextHydrate.isSearchServiceReachable);
+
+
   // Page
   useCurrentCreatedAt(createdAt);
   useDeleteUsername(deleteUsername);

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

@@ -37,7 +37,7 @@ const AdminHome = (props) => {
   }, [fetchAdminHomeData]);
 
   return (
-    <>
+    <div data-testid="admin-home">
       {
       // Alert message will be displayed in case that V5 migration has not been compleated
         (migrationStatus != null && !migrationStatus.isV5Compatible)
@@ -106,7 +106,7 @@ const AdminHome = (props) => {
           </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;
 
     return (
-      <Fragment>
+      <div data-testid="admin-app-settings">
         {
           !isV5Compatible
           && (
@@ -66,7 +66,7 @@ class AppSettingsPageContents extends React.Component {
             <PluginSetting />
           </div>
         </div>
-      </Fragment>
+      </div>
     );
   }
 

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

@@ -46,7 +46,7 @@ function Customize(props) {
   }
 
   return (
-    <Fragment>
+    <div data-testid="admin-customize">
       <div className="mb-5">
         <CustomizeLayoutSetting appContainer={appContainer} />
       </div>
@@ -71,7 +71,7 @@ function Customize(props) {
       <div className="mb-5">
         <CustomizeScriptSetting />
       </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);
 
     return (
-      <Fragment>
+      <div data-testid="admin-export-archive-data">
         <h2>{t('Export Archive Data')}</h2>
 
         <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}
           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;
 
     return (
-      <Fragment>
+      <div data-testid="admin-full-text-search">
         <h2> { t('full_text_search_management.elasticsearch_management') } </h2>
         <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;
 
     return (
-      <Fragment>
+      <div data-testid="admin-import-data">
         <GrowiArchiveSection />
 
         <form
@@ -226,7 +226,7 @@ class ImportDataPageContents extends React.Component {
 
 
         </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;
 
   return (
-    <>
+    <div data-testid="admin-slack-integration-legacy">
       { isDisabled && (
         <div className="alert alert-danger">
           <i className="icon-minus icon-fw"></i>
@@ -58,7 +58,7 @@ function LegacySlackIntegration(props) {
       </div>
 
       <SlackConfiguration />
-    </>
+    </div>
   );
 }
 

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

@@ -14,7 +14,7 @@ class MarkDownSettingContents extends React.Component {
   render() {
     const { t } = this.props;
     return (
-      <React.Fragment>
+      <div data-testid="admin-markdown">
         {/* Line Break Setting */}
         <h2 className="admin-setting-header">{t('admin:markdown_setting.lineBreak_header')}</h2>
         <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>
         </Card>
         <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;
 
   return (
-    <>
+    <div data-testid="admin-notification">
       <h2 className="admin-setting-header">{t('admin:external_notification.header_status')}</h2>
       <ul className="list-group">
         { !isMounted && <SkeltonListItem />}
@@ -170,7 +170,7 @@ function NotificationSetting(props) {
           {activeComponents.has('global_notification') && <GlobalNotification />}
         </TabPane>
       </TabContent>
-    </>
+    </div>
   );
 }
 

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

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

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

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

+ 0 - 3
packages/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx

@@ -7,7 +7,6 @@ import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
-import AppContainer from '~/client/services/AppContainer';
 import { IUserGroupHasId } from '~/interfaces/user';
 import { CustomWindow } from '~/interfaces/global';
 import Xss from '~/services/xss';
@@ -20,8 +19,6 @@ import Xss from '~/services/xss';
  * @extends {React.Component}
  */
 type Props = {
-  appContainer: AppContainer,
-
   userGroups: IUserGroupHasId[],
   deleteUserGroup?: IUserGroupHasId,
   onDelete?: (deleteGroupId: string, actionName: string, transferToUserGroupId: string) => Promise<void> | void,

+ 12 - 27
packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx

@@ -3,17 +3,12 @@ import { useTranslation } from 'react-i18next';
 import dateFnsFormat from 'date-fns/format';
 import { TFunctionResult } from 'i18next';
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
 import { CustomWindow } from '~/interfaces/global';
 import Xss from '~/services/xss';
 
 type Props = {
   userGroup?: IUserGroupHasId,
-  successedMessage: TFunctionResult;
-  failedMessage: TFunctionResult;
   submitButtonLabel: TFunctionResult;
   onSubmit?: (userGroupData: Partial<IUserGroup>) => Promise<IUserGroupHasId | void>
 };
@@ -23,12 +18,14 @@ const UserGroupForm: FC<Props> = (props: Props) => {
 
   const { t } = useTranslation();
 
+  const { userGroup, submitButtonLabel, onSubmit } = props;
+
   /*
    * State
    */
-  const [currentName, setName] = useState(props.userGroup != null ? props.userGroup.name : '');
-  const [currentDescription, setDescription] = useState(props.userGroup != null ? props.userGroup.description : '');
-  const [currentParent, setParent] = useState(props.userGroup != null ? props.userGroup.parent : '');
+  const [currentName, setName] = useState(userGroup != null ? userGroup.name : '');
+  const [currentDescription, setDescription] = useState(userGroup != null ? userGroup.description : '');
+  const [currentParent, setParent] = useState(userGroup != null ? userGroup.parent : '');
 
   /*
    * Function
@@ -44,19 +41,12 @@ const UserGroupForm: FC<Props> = (props: Props) => {
   const onSubmitHandler = useCallback(async(e) => {
     e.preventDefault(); // no reload
 
-    if (props.onSubmit == null) {
+    if (onSubmit == null) {
       return;
     }
 
-    try {
-      await props.onSubmit({ name: currentName, description: currentDescription, parent: currentParent });
-
-      toastSuccess(props.successedMessage);
-    }
-    catch (err) {
-      toastError(props.failedMessage);
-    }
-  }, [currentName, currentDescription, currentParent, props.onSubmit, props.successedMessage, props.failedMessage]);
+    await onSubmit({ name: currentName, description: currentDescription, parent: currentParent });
+  }, [currentName, currentDescription, currentParent, onSubmit]);
 
   return (
     <form onSubmit={onSubmitHandler}>
@@ -65,10 +55,10 @@ const UserGroupForm: FC<Props> = (props: Props) => {
         <h2 className="admin-setting-header">{t('admin:user_group_management.basic_info')}</h2>
         {/* TODO 85062: improve style */}
         {
-          props.userGroup?.createdAt != null && (
+          userGroup?.createdAt != null && (
             <div className="form-group row">
               <p className="col-md-2 col-form-label">{t('Created')}</p>
-              <p className="col-md-4 my-auto">{dateFnsFormat(new Date(props.userGroup.createdAt), 'yyyy-MM-dd')}</p>
+              <p className="col-md-4 my-auto">{dateFnsFormat(new Date(userGroup.createdAt), 'yyyy-MM-dd')}</p>
             </div>
           )
         }
@@ -102,7 +92,7 @@ const UserGroupForm: FC<Props> = (props: Props) => {
         <div className="form-group row">
           <div className="offset-md-2 col-md-10">
             <button type="submit" className="btn btn-primary">
-              {props.submitButtonLabel}
+              {submitButtonLabel}
             </button>
           </div>
         </div>
@@ -111,9 +101,4 @@ const UserGroupForm: FC<Props> = (props: Props) => {
   );
 };
 
-/**
- * Wrapper component for using unstated
- */
-const UserGroupFormWrapper = withUnstatedContainers<unknown, Props>(UserGroupForm, [AppContainer]);
-
-export default UserGroupFormWrapper;
+export default UserGroupForm;

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

@@ -15,6 +15,7 @@ import Xss from '~/services/xss';
 import { CustomWindow } from '~/interfaces/global';
 import { apiv3Delete, apiv3Post } from '~/client/util/apiv3-client';
 import { useSWRxUserGroupList, useSWRxChildUserGroupList, useSWRxUserGroupRelationList } from '~/stores/user-group';
+import { useIsAclEnabled } from '~/stores/context';
 
 type Props = {
   appContainer: AppContainer,
@@ -23,15 +24,21 @@ type Props = {
 const UserGroupPage: FC<Props> = (props: Props) => {
   const xss: Xss = (window as CustomWindow).xss;
   const { t } = useTranslation();
-  const { isAclEnabled } = props.appContainer.config;
+
+  const { data: isAclEnabled } = useIsAclEnabled();
 
   /*
    * Fetch
    */
-  const { data: userGroups, mutate: mutateUserGroups } = useSWRxUserGroupList();
-  const userGroupIds = userGroups?.map(group => group._id);
-  const { data: userGroupRelations, mutate: mutateUserGroupRelations } = useSWRxUserGroupRelationList(userGroupIds);
-  const { data: childUserGroups } = useSWRxChildUserGroupList(userGroupIds);
+  const { data: userGroupList, mutate: mutateUserGroups } = useSWRxUserGroupList();
+  const userGroups = userGroupList != null ? userGroupList : [];
+  const userGroupIds = userGroups.map(group => group._id);
+
+  const { data: userGroupRelationList } = useSWRxUserGroupRelationList(userGroupIds);
+  const userGroupRelations = userGroupRelationList != null ? userGroupRelationList : [];
+
+  const { data: childUserGroupsList } = useSWRxChildUserGroupList(userGroupIds);
+  const childUserGroups = childUserGroupsList != null ? childUserGroupsList.childUserGroups : [];
 
   /*
    * State
@@ -68,21 +75,20 @@ const UserGroupPage: FC<Props> = (props: Props) => {
     setDeleteModalShown(false);
   }, []);
 
-  const addUserGroup = useCallback(async(userGroupData: IUserGroup) => {
+  const createUserGroup = useCallback(async(userGroupData: IUserGroup) => {
     try {
       await apiv3Post('/user-groups', {
         name: userGroupData.name,
         description: userGroupData.description,
         parent: userGroupData.parent,
       });
-
-      // sync
+      toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
       await mutateUserGroups();
     }
     catch (err) {
       toastError(err);
     }
-  }, [mutateUserGroups]);
+  }, [t, mutateUserGroups]);
 
   const deleteUserGroupById = useCallback(async(deleteGroupId: string, actionName: string, transferToUserGroupId: string) => {
     try {
@@ -102,14 +108,10 @@ const UserGroupPage: FC<Props> = (props: Props) => {
     catch (err) {
       toastError(new Error('Unable to delete the groups'));
     }
-  }, [mutateUserGroups, mutateUserGroupRelations]);
-
-  if (userGroups == null || userGroupRelations == null || childUserGroups == null) {
-    return <></>;
-  }
+  }, [mutateUserGroups]);
 
   return (
-    <Fragment>
+    <div data-testid="admin-user-groups">
       {
         isAclEnabled ? (
           <div className="mb-2">
@@ -118,10 +120,8 @@ const UserGroupPage: FC<Props> = (props: Props) => {
             </button>
             <div id="createGroupForm" className="collapse">
               <UserGroupForm
-                successedMessage={t('toaster.create_succeeded', { target: t('UserGroup') })}
-                failedMessage={t('toaster.create_failed', { target: t('UserGroup') })}
                 submitButtonLabel={t('Create')}
-                onSubmit={addUserGroup}
+                onSubmit={createUserGroup}
               />
             </div>
           </div>
@@ -129,24 +129,25 @@ const UserGroupPage: FC<Props> = (props: Props) => {
           t('admin:user_group_management.deny_create_group')
         )
       }
-      <UserGroupTable
-        appContainer={props.appContainer}
-        userGroups={userGroups}
-        childUserGroups={childUserGroups}
-        isAclEnabled={isAclEnabled}
-        onDelete={showDeleteModal}
-        userGroupRelations={userGroupRelations}
-      />
-      <UserGroupDeleteModal
-        appContainer={props.appContainer}
-        userGroups={userGroups}
-        deleteUserGroup={selectedUserGroup}
-        onDelete={deleteUserGroupById}
-        isShow={isDeleteModalShown}
-        onShow={showDeleteModal}
-        onHide={hideDeleteModal}
-      />
-    </Fragment>
+      <>
+        <UserGroupTable
+          headerLabel={t('admin:user_group_management.group_list')}
+          userGroups={userGroups}
+          childUserGroups={childUserGroups}
+          isAclEnabled={isAclEnabled ?? false}
+          onDelete={showDeleteModal}
+          userGroupRelations={userGroupRelations}
+        />
+        <UserGroupDeleteModal
+          userGroups={userGroups}
+          deleteUserGroup={selectedUserGroup}
+          onDelete={deleteUserGroupById}
+          isShow={isDeleteModalShown}
+          onShow={showDeleteModal}
+          onHide={hideDeleteModal}
+        />
+      </>
+    </div>
   );
 };
 

+ 5 - 7
packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx

@@ -2,17 +2,15 @@ import React, {
   FC, useState, useCallback, useEffect,
 } from 'react';
 import { useTranslation } from 'react-i18next';
+import { TFunctionResult } from 'i18next';
 import dateFnsFormat from 'date-fns/format';
 
 import Xss from '~/services/xss';
-import AppContainer from '~/client/services/AppContainer';
 import { IUserGroupHasId, IUserGroupRelation, IUserHasId } from '~/interfaces/user';
 import { CustomWindow } from '~/interfaces/global';
 
-
 type Props = {
-  appContainer: AppContainer,
-
+  headerLabel?: TFunctionResult,
   userGroups: IUserGroupHasId[],
   userGroupRelations: IUserGroupRelation[],
   childUserGroups: IUserGroupHasId[],
@@ -82,7 +80,7 @@ const UserGroupTable: FC<Props> = (props: Props) => {
     }
 
     props.onDelete(group);
-  }, [props.userGroups, props.onDelete]);
+  }, [props]);
 
   /*
    * useEffect
@@ -94,7 +92,7 @@ const UserGroupTable: FC<Props> = (props: Props) => {
 
   return (
     <>
-      <h2>{t('admin:user_group_management.group_list')}</h2>
+      <h2>{props.headerLabel}</h2>
 
       <table className="table table-bordered table-user-list">
         <thead>
@@ -102,7 +100,7 @@ const UserGroupTable: FC<Props> = (props: Props) => {
             <th>{t('Name')}</th>
             <th>{t('Description')}</th>
             <th>{t('User')}</th>
-            <th>{t('Child groups')}</th>
+            <th>{t('ChildUserGroup')}</th>
             <th style={{ width: 100 }}>{t('Created')}</th>
             <th style={{ width: 70 }}></th>
           </tr>

+ 84 - 32
packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -4,50 +4,59 @@ import React, {
 import { useTranslation } from 'react-i18next';
 
 import UserGroupForm from '../UserGroup/UserGroupForm';
+import UserGroupTable from '../UserGroup/UserGroupTable';
+import UserGroupDeleteModal from '../UserGroup/UserGroupDeleteModal';
 import UserGroupDropdown from '../UserGroup/UserGroupDropdown';
 import UserGroupUserTable from './UserGroupUserTable';
 import UserGroupUserModal from './UserGroupUserModal';
 import UserGroupPageList from './UserGroupPageList';
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
+
 import {
   apiv3Get, apiv3Put, apiv3Delete, apiv3Post,
 } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { IPageHasId } from '~/interfaces/page';
 import {
-  IUserGroup, IUserGroupHasId, IUserGroupRelation,
+  IUserGroup, IUserGroupHasId,
 } from '~/interfaces/user';
-import { useSWRxUserGroupPages, useSWRxUserGroupRelations, useSWRxSelectableUserGroups } from '~/stores/user-group';
-
+import {
+  useSWRxUserGroupPages, useSWRxUserGroupRelationList, useSWRxChildUserGroupList, useSWRxSelectableUserGroups,
+} from '~/stores/user-group';
+import { useIsAclEnabled } from '~/stores/context';
 
 const UserGroupDetailPage: FC = () => {
-  const rootElem = document.getElementById('admin-user-group-detail');
   const { t } = useTranslation();
+  const adminUserGroupDetailElem = document.getElementById('admin-user-group-detail');
 
   /*
    * State (from AdminUserGroupDetailContainer)
    */
-  const [userGroup, setUserGroup] = useState<IUserGroupHasId>(JSON.parse(rootElem?.getAttribute('data-user-group') || 'null'));
-
-  // TODO 85062: /_api/v3/user-groups/children?include_grand_child=boolean
-  const [childUserGroups, setChildUserGroups] = useState<IUserGroupHasId[]>([]); // TODO 85062: fetch data on init (findChildGroupsByParentIds) For child group list
-  const [grandChildUserGroups, setGrandChildUserGroups] = useState<IUserGroupHasId[]>([]); // TODO 85062: fetch data on init (findChildGroupsByParentIds) For child group list
-
-  const [childUserGroupRelations, setChildUserGroupRelations] = useState<IUserGroupRelation[]>([]); // TODO 85062: fetch data on init (findRelationsByGroupIds) For child group list
+  const [userGroup, setUserGroup] = useState<IUserGroupHasId>(JSON.parse(adminUserGroupDetailElem?.getAttribute('data-user-group') || 'null'));
   const [relatedPages, setRelatedPages] = useState<IPageHasId[]>([]); // For page list
   const [isUserGroupUserModalOpen, setUserGroupUserModalOpen] = useState<boolean>(false);
   const [searchType, setSearchType] = useState<string>('partial');
   const [isAlsoMailSearched, setAlsoMailSearched] = useState<boolean>(false);
   const [isAlsoNameSearched, setAlsoNameSearched] = useState<boolean>(false);
+  const [selectedUserGroup, setSelectedUserGroup] = useState<IUserGroupHasId | undefined>(undefined); // not null but undefined (to use defaultProps in UserGroupDeleteModal)
+  const [isDeleteModalShown, setDeleteModalShown] = useState<boolean>(false);
 
   /*
    * Fetch
    */
   const { data: userGroupPages } = useSWRxUserGroupPages(userGroup._id, 10, 0);
-  const { data: userGroupRelations, mutate: mutateUserGroupRelations } = useSWRxUserGroupRelations(userGroup._id);
+
+  const { data: childUserGroupsList, mutate: mutateChildUserGroups } = useSWRxChildUserGroupList([userGroup._id], true);
+  const childUserGroups = childUserGroupsList != null ? childUserGroupsList.childUserGroups : [];
+  const grandChildUserGroups = childUserGroupsList != null ? childUserGroupsList.grandChildUserGroups : [];
+  const childUserGroupIds = childUserGroups.map(group => group._id);
+
+  const { data: userGroupRelationList, mutate: mutateUserGroupRelations } = useSWRxUserGroupRelationList(childUserGroupIds);
+  const childUserGroupRelations = userGroupRelationList != null ? userGroupRelationList : [];
+
   const { data: selectableUserGroups, mutate: mutateSelectableUserGroups } = useSWRxSelectableUserGroups(userGroup._id);
 
+  const { data: isAclEnabled } = useIsAclEnabled();
+
   /*
    * Function
    */
@@ -66,13 +75,16 @@ const UserGroupDetailPage: FC = () => {
   }, []);
 
   const updateUserGroup = useCallback(async(param: Partial<IUserGroup>) => {
-    const res = await apiv3Put<{ userGroup: IUserGroupHasId }>(`/user-groups/${userGroup._id}`, param);
-    const { userGroup: newUserGroup } = res.data;
-
-    setUserGroup(newUserGroup);
-
-    return newUserGroup;
-  }, [userGroup]);
+    try {
+      const res = await apiv3Put<{ userGroup: IUserGroupHasId }>(`/user-groups/${userGroup._id}`, param);
+      const { userGroup: newUserGroup } = res.data;
+      setUserGroup(newUserGroup);
+      toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [t, userGroup._id, setUserGroup]);
 
   const openUserGroupUserModal = useCallback(() => {
     setUserGroupUserModalOpen(true);
@@ -112,9 +124,10 @@ const UserGroupDetailPage: FC = () => {
         name: selectedUserGroup.name,
         description: selectedUserGroup.description,
         parentId: userGroup._id,
-        forceUpdateParents: false, //  TODO 87748: Make forceUpdateParents optionally selectable
+        forceUpdateParents: false,
       });
       mutateSelectableUserGroups();
+      mutateChildUserGroups();
       toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
     }
     catch (err) {
@@ -127,6 +140,36 @@ const UserGroupDetailPage: FC = () => {
     console.log('button clicked!');
   };
 
+  const showDeleteModal = useCallback(async(group: IUserGroupHasId) => {
+    setSelectedUserGroup(group);
+    setDeleteModalShown(true);
+  }, [setSelectedUserGroup, setDeleteModalShown]);
+
+  const hideDeleteModal = useCallback(() => {
+    setSelectedUserGroup(undefined);
+    setDeleteModalShown(false);
+  }, [setSelectedUserGroup, setDeleteModalShown]);
+
+  const deleteChildUserGroupById = useCallback(async(deleteGroupId: string, actionName: string, transferToUserGroupId: string) => {
+    try {
+      const res = await apiv3Delete(`/user-groups/${deleteGroupId}`, {
+        actionName,
+        transferToUserGroupId,
+      });
+
+      // sync
+      await mutateChildUserGroups();
+
+      setSelectedUserGroup(undefined);
+      setDeleteModalShown(false);
+
+      toastSuccess(`Deleted ${res.data.userGroups.length} groups.`);
+    }
+    catch (err) {
+      toastError(new Error('Unable to delete the groups'));
+    }
+  }, [mutateChildUserGroups, setSelectedUserGroup, setDeleteModalShown]);
+
   /*
    * Dependencies
    */
@@ -144,8 +187,6 @@ const UserGroupDetailPage: FC = () => {
       <div className="mt-4 form-box">
         <UserGroupForm
           userGroup={userGroup}
-          successedMessage={t('toaster.update_successed', { target: t('UserGroup') })}
-          failedMessage={t('toaster.update_failed', { target: t('UserGroup') })}
           submitButtonLabel={t('Update')}
           onSubmit={updateUserGroup}
         />
@@ -161,19 +202,30 @@ const UserGroupDetailPage: FC = () => {
         onClickCreateUserGroupButtonHandler={() => onClickCreateChildGroupButtonHandler()}
       />
 
+      <>
+        <UserGroupTable
+          userGroups={childUserGroups}
+          childUserGroups={grandChildUserGroups}
+          isAclEnabled={isAclEnabled ?? false}
+          onDelete={showDeleteModal}
+          userGroupRelations={childUserGroupRelations}
+        />
+        <UserGroupDeleteModal
+          userGroups={childUserGroups}
+          deleteUserGroup={selectedUserGroup}
+          onDelete={deleteChildUserGroupById}
+          isShow={isDeleteModalShown}
+          onShow={showDeleteModal}
+          onHide={hideDeleteModal}
+        />
+      </>
+
       <h2 className="admin-setting-header mt-4">{t('Page')}</h2>
       <div className="page-list">
         <UserGroupPageList />
       </div>
     </div>
   );
-
 };
 
-
-/**
- * Wrapper component for using unstated
- */
-const UserGroupDetailPageWrapper = withUnstatedContainers(UserGroupDetailPage, [AppContainer]);
-
-export default UserGroupDetailPageWrapper;
+export default UserGroupDetailPage;

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

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

+ 24 - 12
packages/app/src/components/DescendantsPageList.tsx

@@ -3,7 +3,7 @@ import {
   IPageHasId, IPageWithMeta,
 } from '~/interfaces/page';
 import { IPagingResult } from '~/interfaces/paging-result';
-import { useIsGuestUser, useIsSharedUser } from '~/stores/context';
+import { useCurrentPagePath, useIsGuestUser, useIsSharedUser } from '~/stores/context';
 
 import { useSWRxPageInfoForList, useSWRxPageList } from '~/stores/page';
 
@@ -19,7 +19,7 @@ const convertToIPageWithEmptyMeta = (page: IPageHasId): IPageWithMeta => {
   return { pageData: page };
 };
 
-const DescendantsPageList = (props: Props): JSX.Element => {
+export const DescendantsPageList = (props: Props): JSX.Element => {
   const { path } = props;
 
   const [activePage, setActivePage] = useState(1);
@@ -86,21 +86,33 @@ const DescendantsPageList = (props: Props): JSX.Element => {
     );
   }
 
+  const showPager = pagingResult.items.length > pagingResult.limit;
+
   return (
     <>
       <PageList pages={pagingResultWithMeta} isEnableActions={!isGuestUser} />
 
-      <div className="my-4">
-        <PaginationWrapper
-          activePage={activePage}
-          changePage={setPageNumber}
-          totalItemsCount={pagingResult.totalCount}
-          pagingLimit={pagingResult.limit}
-          align="center"
-        />
-      </div>
+      { showPager && (
+        <div className="my-4">
+          <PaginationWrapper
+            activePage={activePage}
+            changePage={setPageNumber}
+            totalItemsCount={pagingResult.totalCount}
+            pagingLimit={pagingResult.limit}
+            align="center"
+          />
+        </div>
+      ) }
     </>
   );
 };
 
-export default DescendantsPageList;
+export const DescendantsPageListForCurrentPath = (): JSX.Element => {
+
+  const { data: path } = useCurrentPagePath();
+
+  return path != null
+    ? <DescendantsPageList path={path} />
+    : <></>;
+
+};

+ 1 - 1
packages/app/src/components/DescendantsPageListModal.tsx

@@ -9,7 +9,7 @@ import {
 import { useDescendantsPageListModal } from '~/stores/modal';
 import { useIsSharedUser } from '~/stores/context';
 
-import DescendantsPageList from './DescendantsPageList';
+import { DescendantsPageList } from './DescendantsPageList';
 import ExpandOrContractButton from './ExpandOrContractButton';
 import { CustomNavTab } from './CustomNavigation/CustomNav';
 import PageListIcon from './Icons/PageListIcon';

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

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
 
 import PageListIcon from './Icons/PageListIcon';
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
-import DescendantsPageList from './DescendantsPageList';
+import { DescendantsPageListForCurrentPath } from './DescendantsPageList';
 
 
 type Props = {
@@ -17,7 +17,7 @@ const ForbiddenPage = React.memo((props: Props): JSX.Element => {
     return {
       pagelist: {
         Icon: PageListIcon,
-        Content: DescendantsPageList,
+        Content: DescendantsPageListForCurrentPath,
         i18n: t('page_list'),
         index: 0,
       },

+ 0 - 1
packages/app/src/components/IdenticalPathPage.tsx

@@ -111,7 +111,6 @@ const IdenticalPathPage:FC<IdenticalPathPageProps> = (props: IdenticalPathPagePr
                   isSelected={false}
                   isEnableActions
                   showPageUpdatedTime
-                // Todo: add onClickDeleteButton when delete feature implemented
                 />
               );
             })}

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

@@ -294,6 +294,7 @@ const GrowiContextualSubNavigation = (props) => {
       tags={tagsInfoData?.tags || []}
       tagsUpdatedHandler={tagsUpdatedHandler}
       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
           className="px-md-3 nav-link btn-create-page border-0 bg-transparent"
           type="button"
+          data-testid="newPageBtn"
           onClick={() => openCreateModal(currentPagePath || '')}
         >
           <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>,
 
   controls?: React.FunctionComponent,
+  additionalClasses?: string[],
 }
 
 export const GrowiSubNavigation = (props: Props): JSX.Element => {
@@ -41,6 +42,7 @@ export const GrowiSubNavigation = (props: Props): JSX.Element => {
     isGuestUser, isDrawerMode, isCompactMode,
     tags, tagsUpdatedHandler,
     controls: Controls,
+    additionalClasses = [],
   } = props;
 
   const {
@@ -56,7 +58,11 @@ export const GrowiSubNavigation = (props: Props): JSX.Element => {
   }
 
   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 */}
       <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 (
     <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 />
       </div>
     </div>

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

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

+ 4 - 13
packages/app/src/components/NotFoundPage.tsx

@@ -1,30 +1,21 @@
-import React, { useCallback, useMemo } from 'react';
+import React, { useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import PageListIcon from './Icons/PageListIcon';
 import TimeLineIcon from './Icons/TimeLineIcon';
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
-import DescendantsPageList from './DescendantsPageList';
+import { DescendantsPageListForCurrentPath } from './DescendantsPageList';
 import PageTimeline from './PageTimeline';
-import { useCurrentPagePath } from '~/stores/context';
 
 
 const NotFoundPage = (): JSX.Element => {
   const { t } = useTranslation();
 
-  const { data: currentPagePath } = useCurrentPagePath();
-
-  const DescendantsPageListForThisPage = useCallback((): JSX.Element => {
-    return currentPagePath != null
-      ? <DescendantsPageList path={currentPagePath} />
-      : <></>;
-  }, [currentPagePath]);
-
   const navTabMapping = useMemo(() => {
     return {
       pagelist: {
         Icon: PageListIcon,
-        Content: DescendantsPageListForThisPage,
+        Content: DescendantsPageListForCurrentPath,
         i18n: t('page_list'),
         index: 0,
       },
@@ -35,7 +26,7 @@ const NotFoundPage = (): JSX.Element => {
         index: 1,
       },
     };
-  }, [DescendantsPageListForThisPage, t]);
+  }, [t]);
 
 
   return (

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

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

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

@@ -165,7 +165,12 @@ const PageCreateModal = (props) => {
             </div>
 
             <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')}
               </button>
             </div>
@@ -179,7 +184,7 @@ const PageCreateModal = (props) => {
 
   function renderInputPageForm() {
     return (
-      <div className="row">
+      <div className="row" data-testid="row-create-page-under-below">
         <fieldset className="col-12 mb-4">
           <h3 className="grw-modal-head pb-2">{t('Create under')}</h3>
 
@@ -211,7 +216,12 @@ const PageCreateModal = (props) => {
             </div>
 
             <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')}
               </button>
             </div>
@@ -275,6 +285,7 @@ const PageCreateModal = (props) => {
       size="lg"
       isOpen={isOpened}
       toggle={() => closeCreateModal()}
+      data-testid="page-create-modal"
       className="grw-create-page"
       autoFocus={false}
     >

+ 8 - 3
packages/app/src/components/PageList/PageListItemL.tsx

@@ -12,7 +12,7 @@ import urljoin from 'url-join';
 import { UserPicture, PageListMeta } from '@growi/ui';
 import { DevidedPagePath } from '@growi/core';
 import { useIsDeviceSmallerThanLg } from '~/stores/ui';
-import { usePageRenameModal, usePageDuplicateModal } from '~/stores/modal';
+import { usePageRenameModal, usePageDuplicateModal, usePageDeleteModal } from '~/stores/modal';
 import {
   IPageInfoAll, IPageWithMeta, isIPageInfoForEntity, isIPageInfoForListing,
 } from '~/interfaces/page';
@@ -30,7 +30,6 @@ type Props = {
   showPageUpdatedTime?: boolean, // whether to show page's updated time at the top-right corner of item
   onCheckboxChanged?: (isChecked: boolean, pageId: string) => void,
   onClickItem?: (pageId: string) => void,
-  onClickDeleteButton?: (pageId: string) => void,
 }
 
 const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (props: Props, ref): JSX.Element => {
@@ -62,6 +61,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   const { data: isDeviceSmallerThanLg } = useIsDeviceSmallerThanLg();
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openRenameModal } = usePageRenameModal();
+  const { open: openDeleteModal } = usePageDeleteModal();
 
   const elasticSearchResult = isIPageSearchMeta(pageMeta) ? pageMeta.elasticSearchResult : null;
   const revisionShortBody = isIPageInfoForListing(pageMeta) ? pageMeta.revisionShortBody : null;
@@ -94,6 +94,11 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
     openRenameModal(pageId, revisionId as string, path);
   }, [openRenameModal, pageData]);
 
+  const deleteMenuItemClickHandler = useCallback(() => {
+    const { _id: pageId, revision: revisionId, path } = pageData;
+    openDeleteModal([{ pageId, revisionId: revisionId as string, path }]);
+  }, [openDeleteModal, pageData]);
+
   const styleListGroupItem = (!isDeviceSmallerThanLg && onClickItem != null) ? 'list-group-item-action' : '';
   // background color of list item changes when class "active" exists under 'list-group-item'
   const styleActive = !isDeviceSmallerThanLg && isSelected ? 'active' : '';
@@ -156,7 +161,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
                 <PageItemControl
                   pageId={pageData._id}
                   pageInfo={pageMeta}
-                  onClickDeleteMenuItem={props.onClickDeleteButton}
+                  onClickDeleteMenuItem={deleteMenuItemClickHandler}
                   onClickRenameMenuItem={renameMenuItemClickHandler}
                   isEnableActions={isEnableActions}
                   onClickDuplicateMenuItem={duplicateMenuItemClickHandler}

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

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

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

@@ -8,6 +8,7 @@ import { parse as parseQuerystring } from 'querystring';
 import AppContainer from '~/client/services/AppContainer';
 import { IFormattedSearchResult } from '~/interfaces/search';
 import { ISelectableAll, ISelectableAndIndeterminatable } from '~/client/interfaces/selectable-all';
+import { useIsSearchServiceConfigured, useIsSearchServiceReachable } from '~/stores/context';
 import { ISearchConditions, ISearchConfigurations, useSWRxFullTextSearch } from '~/stores/search';
 
 import PaginationWrapper from './PaginationWrapper';
@@ -46,16 +47,18 @@ const SearchResultListHead = React.memo((props: SearchResultListHeadProps): JSX.
   const rightNum = offset + hitsCount;
 
   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">
         {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 && (
           <span className="ml-3 text-muted">({took}ms)</span>
         ) }
       </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">
           <label className="input-group-text text-muted" htmlFor="inputGroupSelect01">{t('search_result.number_of_list_to_display')}</label>
         </div>
@@ -100,8 +103,7 @@ export const SearchPage = (props: Props): JSX.Element => {
   const initQ = (Array.isArray(parsedQueries) ? parsedQueries.join(' ') : parsedQueries) ?? '';
 
   const [keyword, setKeyword] = useState<string>(initQ);
-  const [configurationsByControl, setConfigurationsByControl] = useState<Partial<ISearchConfigurations>>({
-  });
+  const [configurationsByControl, setConfigurationsByControl] = useState<Partial<ISearchConfigurations>>({});
   const [configurationsByPagination, setConfigurationsByPagination] = useState<Partial<ISearchConfigurations>>({
     limit: INITIAL_PAGIONG_SIZE,
   });
@@ -109,6 +111,9 @@ export const SearchPage = (props: Props): JSX.Element => {
   const selectAllControlRef = useRef<ISelectableAndIndeterminatable|null>(null);
   const searchPageBaseRef = useRef<ISelectableAll|null>(null);
 
+  const { data: isSearchServiceConfigured } = useIsSearchServiceConfigured();
+  const { data: isSearchServiceReachable } = useIsSearchServiceReachable();
+
   const { data, conditions } = useSWRxFullTextSearch(keyword, {
     limit: INITIAL_PAGIONG_SIZE,
     ...configurationsByControl,
@@ -201,15 +206,19 @@ export const SearchPage = (props: Props): JSX.Element => {
   }, [hitsCount, selectAllCheckboxChangedHandler, t]);
 
   const searchControl = useMemo(() => {
+    if (!isSearchServiceReachable) {
+      return <></>;
+    }
     return (
       <SearchControl
+        isSearchServiceReachable={isSearchServiceReachable}
         initialSearchConditions={initialSearchConditions}
         onSearchInvoked={searchInvokedHandler}
         deleteAllControl={deleteAllControl}
       >
       </SearchControl>
     );
-  }, [deleteAllControl, initialSearchConditions, searchInvokedHandler]);
+  }, [deleteAllControl, initialSearchConditions, isSearchServiceReachable, searchInvokedHandler]);
 
   const searchResultListHead = useMemo(() => {
     if (data == null) {
@@ -245,6 +254,30 @@ export const SearchPage = (props: Props): JSX.Element => {
     );
   }, [conditions, configurationsByPagination?.limit, data, pagingNumberChangedHandler]);
 
+  if (!isSearchServiceConfigured) {
+    return (
+      <div className="grw-container-convertible">
+        <div className="row mt-5">
+          <div className="col text-muted">
+            <h1>Search service is not configured in this system.</h1>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+  if (!isSearchServiceReachable) {
+    return (
+      <div className="grw-container-convertible">
+        <div className="row mt-5">
+          <div className="col text-muted">
+            <h1>Search service occures errors. Please contact to administrators of this system.</h1>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
   return (
     <SearchPageBase
       ref={searchPageBaseRef}

+ 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 { ISearchConditions, ISearchConfigurations } from '~/stores/search';
 
-import SearchPageForm from './SearchPageForm';
 import SearchOptionModal from './SearchOptionModal';
 import SortControl from './SortControl';
+import SearchForm from '../SearchForm';
 
 type Props = {
+  isSearchServiceReachable: boolean,
   initialSearchConditions: Partial<ISearchConditions>,
 
   onSearchInvoked: (keyword: string, configurations: Partial<ISearchConfigurations>) => void,
@@ -21,6 +22,7 @@ type Props = {
 const SearchControl: FC <Props> = React.memo((props: Props) => {
 
   const {
+    isSearchServiceReachable,
     initialSearchConditions,
     onSearchInvoked,
     deleteAllControl,
@@ -45,8 +47,8 @@ const SearchControl: FC <Props> = React.memo((props: Props) => {
     });
   }, [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) => {
@@ -62,9 +64,11 @@ const SearchControl: FC <Props> = React.memo((props: Props) => {
     <div className="position-sticky fixed-top shadow-sm">
       <div className="grw-search-page-nav d-flex py-3 align-items-center">
         <div className="flex-grow-1 mx-4">
-          <SearchPageForm
+          <SearchForm
+            isSearchServiceReachable={isSearchServiceReachable}
             keyword={keyword}
-            onSearchFormChanged={searchFormChangedHandler}
+            disableIncrementalSearch
+            onSubmit={searchFormSubmittedHandler}
           />
         </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}
             showPageControlDropdown={showPageControlDropdown}
             additionalMenuItemRenderer={props => <AdditionalMenuItems {...props} pageId={page._id} revisionId={revisionId} />}
+            isCompactMode
             onClickDuplicateMenuItem={duplicateItemClickedHandler}
             onClickRenameMenuItem={renameItemClickedHandler}
             onClickDeleteMenuItem={deleteItemClickedHandler}
@@ -153,12 +154,16 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
   if (page == null) 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
           growiRenderer={growiRenderer}
           pageId={page._id}

+ 2 - 6
packages/app/src/components/SearchPage/SearchResultList.tsx

@@ -56,9 +56,6 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
     }
   }, [onPageSelected, pages]);
 
-  const clickDeleteButtonHandler = useCallback((pageId: string) => {
-    // TODO implement
-  }, []);
 
   let injectedPage;
   // inject data to list
@@ -82,7 +79,7 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
   }
 
   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) => {
         return (
           <PageListItemL
@@ -90,11 +87,10 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
             // eslint-disable-next-line no-return-assign
             ref={c => itemsRef.current[i] = c}
             page={page}
-            isEnableActions={isGuestUser}
+            isEnableActions={!isGuestUser}
             isSelected={page.pageData._id === selectedPageId}
             onClickItem={clickItemHandler}
             onCheckboxChanged={props.onCheckboxChanged}
-            onClickDeleteButton={clickDeleteButtonHandler}
           />
         );
       })}

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

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

+ 31 - 17
packages/app/src/components/SearchPage2/SearchPageBase.tsx

@@ -1,6 +1,7 @@
 import React, {
   forwardRef, ForwardRefRenderFunction, useEffect, useImperativeHandle, useRef, useState,
 } from 'react';
+import { useTranslation } from 'react-i18next';
 import { ISelectableAll } from '~/client/interfaces/selectable-all';
 import AppContainer from '~/client/services/AppContainer';
 import { IPageWithMeta } from '~/interfaces/page';
@@ -23,6 +24,8 @@ type Props = {
 }
 
 const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll, Props> = (props:Props, ref) => {
+  const { t } = useTranslation();
+
   const {
     appContainer,
     pages,
@@ -103,11 +106,9 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll, Props> =
     }
   }, [onSelectedPagesByCheckboxesChanged, pages, selectedPageIdsByCheckboxes]);
 
-  const isLoading = pages == null;
-
   return (
     <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">
 
@@ -115,27 +116,40 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll, Props> =
 
           <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">
                 <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
               </div>
             ) }
 
-            { !isLoading && (
+            {/* Loaded */}
+            { pages != null && (
               <>
-                <div className="my-3 px-md-4">
+                <div className="my-3 px-md-4 px-3">
                   {searchResultListHead}
                 </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">
                   {searchPager}
                 </div>
@@ -152,7 +166,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll, Props> =
               appContainer={appContainer}
               pageWithMeta={selectedPageWithMeta}
               highlightKeywords={highlightKeywords}
-              showPageControlDropdown={isGuestUser}
+              showPageControlDropdown={!isGuestUser}
             />
           )}
         </div>

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

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

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

@@ -108,9 +108,12 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   const [{ isDragging }, drag] = useDrag(() => ({
     type: 'PAGE_TREE',
     item: { page },
-    end: () => {
+    end: (item, monitor) => {
       // in order to set d-none to dropped Item
-      setShouldHide(true);
+      const dropResult = monitor.getDropResult();
+      if (dropResult != null) {
+        setShouldHide(true);
+      }
     },
     collect: monitor => ({
       isDragging: monitor.isDragging(),

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

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import PageListIcon from './Icons/PageListIcon';
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
-import DescendantsPageList from './DescendantsPageList';
+import { DescendantsPageListForCurrentPath } from './DescendantsPageList';
 
 
 const TrashPageList = (props) => {
@@ -13,7 +13,7 @@ const TrashPageList = (props) => {
     return {
       pagelist: {
         Icon: PageListIcon,
-        Content: DescendantsPageList,
+        Content: DescendantsPageListForCurrentPath,
         i18n: t('page_list'),
         index: 0,
       },
@@ -21,7 +21,7 @@ const TrashPageList = (props) => {
   }, [t]);
 
   return (
-    <div className="mt-5 d-edit-none">
+    <div data-testid="trash-page-list" className="mt-5 d-edit-none">
       <CustomNavAndContents navTabMapping={navTabMapping} />
     </div>
   );

+ 1 - 0
packages/app/src/interfaces/user-group-response.ts

@@ -7,6 +7,7 @@ export type UserGroupListResult = {
 
 export type ChildUserGroupListResult = {
   childUserGroups: IUserGroupHasId[],
+  grandChildUserGroups: IUserGroupHasId[],
 };
 
 export type UserGroupRelationListResult = {

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

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

+ 1 - 1
packages/app/src/server/middlewares/auto-reconnect-to-search.js

@@ -21,7 +21,7 @@ module.exports = (crowi) => {
   };
 
   return (req, res, next) => {
-    if (searchService != null && !searchService.isReachable) {
+    if (searchService != null && searchService.isConfigured && !searchService.isReachable) {
       // NON-BLOCKING CALL
       // for the latency of the response
       nextTick(reconnectContext, reconnectHandler);

+ 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
    */
-  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
   await this.createEmptyPagesByPaths(ancestorPaths);

+ 0 - 2
packages/app/src/server/routes/admin.js

@@ -239,12 +239,10 @@ module.exports = function(crowi, app) {
   actions.userGroup = {};
   actions.userGroup.index = function(req, res) {
     const page = parseInt(req.query.page) || 1;
-    const isAclEnabled = aclService.isAclEnabled();
     const renderVar = {
       userGroups: [],
       userGroupRelations: new Map(),
       pager: null,
-      isAclEnabled,
     };
 
     UserGroup.findUserGroupsWithPagination({ page })

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

@@ -186,7 +186,7 @@ module.exports = function(crowi, app) {
   app.get('/_api/attachments.limit'   , accessTokenParser , loginRequiredStrictly, attachment.api.limit);
 
   app.get('/trash$'                   , loginRequired, injectUserUISettings, page.trashPageShowWrapper);
-  app.get('/trash/$'                  , loginRequired, injectUserUISettings, page.trashPageListShowWrapper);
+  app.get('/trash/$'                  , loginRequired, (req, res) => res.redirect('/trash'));
   app.get('/trash/*/$'                , loginRequired, injectUserUISettings, page.deletedPageListShowWrapper);
 
   app.get('/_hackmd/load-agent'          , hackmd.loadAgent);

+ 0 - 10
packages/app/src/server/routes/page.js

@@ -532,16 +532,6 @@ module.exports = function(crowi, app) {
     return res.render('layout-growi/shared_page', renderVars);
   };
 
-  /**
-   * switch action by behaviorType
-   */
-  /* eslint-disable no-else-return */
-  actions.trashPageListShowWrapper = function(req, res) {
-    // redirect to '/trash'
-    return res.redirect('/trash');
-  };
-  /* eslint-enable no-else-return */
-
   /**
    * switch action by behaviorType
    */

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

@@ -113,7 +113,7 @@ module.exports = function(crowi, app) {
   api.search = async function(req, res) {
     const user = req.user;
     const {
-      q: keyword = null, type = null, sort = null, order = null,
+      q = null, type = null, sort = null, order = null,
     } = req.query;
     let paginateOpts;
 
@@ -124,8 +124,8 @@ module.exports = function(crowi, app) {
       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;
@@ -146,6 +146,7 @@ module.exports = function(crowi, app) {
     let searchResult;
     let delegatorName;
     try {
+      const keyword = decodeURIComponent(q);
       [searchResult, delegatorName] = await searchService.searchKeyword(keyword, user, userGroups, searchOpts);
     }
     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 loggerFactory from '~/utils/logger';
@@ -9,7 +11,7 @@ import ConfigModel, {
 
 const logger = loggerFactory('growi:service:ConfigLoader');
 
-enum ValueType { NUMBER, STRING, BOOLEAN }
+enum ValueType { NUMBER, STRING, BOOLEAN, DATE }
 
 interface ValueParser<T> {
   parse(value: string): T;
@@ -26,10 +28,11 @@ type EnumDictionary<T extends string | symbol | number, 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.STRING]:  { parse: (v: string) => { return 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,
     default: null,
   },
+  AUTO_INSTALL_SERVER_DATE: {
+    ns:      'crowi',
+    key:     'autoInstall:serverDate',
+    type:    ValueType.DATE,
+    default: null,
+  },
   S2SMSG_PUBSUB_SERVER_TYPE: {
     ns:      'crowi',
     key:     's2sMessagingPubsub:serverType',

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

@@ -34,7 +34,12 @@ export class InstallerService {
       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> {
@@ -53,7 +58,7 @@ export class InstallerService {
   }
 
   // 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;
     // create /Sandbox/*
     /*
@@ -66,6 +71,19 @@ export class InstallerService {
       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 {
       await this.initSearchIndex();
     }
@@ -85,7 +103,7 @@ export class InstallerService {
     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);
 
     // 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;
 
     // 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
     // TODO: with transaction
@@ -120,7 +142,7 @@ export class InstallerService {
     await Promise.all([rootPage.save(), rootRevision.save()]);
 
     // create initial pages
-    await this.createInitialPages(adminUser, globalLang);
+    await this.createInitialPages(adminUser, globalLang, initialPagesCreatedAt);
 
     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();
     if (this.isElasticsearchReindexOnBoot) {
-      return this.rebuildIndex();
+      try {
+        await this.rebuildIndex();
+      }
+      catch (err) {
+        logger.error('Rebuild index on boot failed', err);
+      }
+      return;
     }
     return normalizeIndices;
   }
@@ -284,7 +290,8 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
       await this.addAllPages();
     }
     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();
       socket.emit('rebuildingFailed', { error: error.message });
@@ -292,6 +299,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
       throw error;
     }
     finally {
+      logger.warn('Normalize indices anyway.');
       await this.normalizeIndices();
     }
 
@@ -325,8 +333,18 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
   }
 
   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> {
+
     // for debug
     if (process.env.NODE_ENV === 'development') {
+      logger.debug('query: ', { query });
+
       const { body: result } = await this.client.indices.validateQuery({
+        index: query.index,
+        type: query.type,
         explain: true,
         body: {
           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);
 
-    // for debug
-    logger.debug('ES result: ', result);
-
     const totalValue = this.isElasticsearchV6 ? result.hits.total : result.hits.total.value;
 
     return {
@@ -665,9 +686,9 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
     // eslint-disable-next-line prefer-const
     let query = {
       index: this.aliasName,
+      _source: fields,
       body: {
         query: {}, // query
-        _source: fields,
       },
     };
 
@@ -687,7 +708,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
     // default sort order is score descending
     const sort = ES_SORT_AXIS[sortAxis] || ES_SORT_AXIS[RELATION_SCORE];
     const order = ES_SORT_ORDER[sortOrder] || ES_SORT_ORDER[DESC];
-    query.body.sort = { [sort]: { order } };
+    query.sort = { [sort]: { order } };
   }
 
   convertSortQuery(sortAxis) {

+ 2 - 2
packages/app/src/server/service/user-group.ts

@@ -2,7 +2,7 @@ import mongoose from 'mongoose';
 
 import loggerFactory from '~/utils/logger';
 import UserGroup, { UserGroupDocument } from '~/server/models/user-group';
-import { isIncludesObjectId } from '~/server/util/compare-objectId';
+import { excludeTestIdsFromTargetIds, isIncludesObjectId } from '~/server/util/compare-objectId';
 
 const logger = loggerFactory('growi:service:UserGroupService'); // eslint-disable-line no-unused-vars
 
@@ -72,7 +72,7 @@ class UserGroupService {
     const [targetGroupUsers, parentGroupUsers] = await Promise.all(
       [UserGroupRelation.findUserIdsByGroupId(userGroup._id), UserGroupRelation.findUserIdsByGroupId(parent._id)],
     );
-    const usersBelongsToTargetButNotParent = targetGroupUsers.filter(user => !parentGroupUsers.includes(user));
+    const usersBelongsToTargetButNotParent = excludeTestIdsFromTargetIds(targetGroupUsers, parentGroupUsers);
 
     // save if no users exist in both target and parent groups
     if (targetGroupUsers.length === 0 && parentGroupUsers.length === 0) {

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

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

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

@@ -9,7 +9,7 @@
 <div class="grw-container-convertible">
   <div class="row">
     <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><!-- /.container-fluid -->

+ 12 - 0
packages/app/src/stores/context.tsx

@@ -131,6 +131,18 @@ export const useNotFoundTargetPathOrId = (initialData?: Nullable<NotFoundTargetP
   return useStaticSWR<Nullable<NotFoundTargetPathOrId>, Error>('notFoundTargetPathOrId', initialData);
 };
 
+export const useIsAclEnabled = (initialData?: boolean) : SWRResponse<boolean, Error> => {
+  return useStaticSWR<boolean, Error>('isAclEnabled', initialData);
+};
+
+export const useIsSearchServiceConfigured = (initialData?: boolean) : SWRResponse<boolean, Error> => {
+  return useStaticSWR<boolean, Error>('isSearchServiceConfigured', initialData);
+};
+
+export const useIsSearchServiceReachable = (initialData?: boolean) : SWRResponse<boolean, Error> => {
+  return useStaticSWR<boolean, Error>('isSearchServiceReachable', initialData);
+};
+
 
 /** **********************************************************
  *                     Computed contexts

+ 4 - 7
packages/app/src/stores/user-group.tsx

@@ -21,17 +21,14 @@ export const useSWRxUserGroupList = (initialData?: IUserGroupHasId[]): SWRRespon
 };
 
 export const useSWRxChildUserGroupList = (
-    parentIds?: string[], includeGrandChildren?: boolean, initialData?: IUserGroupHasId[],
-): SWRResponse<IUserGroupHasId[], Error> => {
+    parentIds?: string[], includeGrandChildren?: boolean,
+): SWRResponse<ChildUserGroupListResult, Error> => {
   const shouldFetch = parentIds != null && parentIds.length > 0;
-  return useSWRImmutable<IUserGroupHasId[], Error>(
+  return useSWRImmutable<ChildUserGroupListResult, Error>(
     shouldFetch ? ['/user-groups/children', parentIds, includeGrandChildren] : null,
     (endpoint, parentIds, includeGrandChildren) => apiv3Get<ChildUserGroupListResult>(
       endpoint, { parentIds, includeGrandChildren },
-    ).then(result => result.data.childUserGroups),
-    {
-      fallbackData: initialData,
-    },
+    ).then((result => result.data)),
   );
 };
 

+ 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));
 
       > h2 {
@@ -206,13 +206,12 @@
         margin-top: 0;
       }
 
-      .search-result-page-content {
+      .search-result-content-body-container {
         overflow-y: auto;
 
         .wiki {
           padding: 16px;
           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
  */
@@ -145,10 +154,6 @@ $easeInOutCubic: cubic-bezier(0.65, 0, 0.35, 1);
 .grw-subnav-fixed-container {
   top: $grw-navbar-border-width;
   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
  */
-.search-result {
+.search-result-base {
   .grw-search-page-nav {
     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', () => {
     cy.visit('/admin');
+    cy.getByTestid('admin-home').should('be.visible');
     cy.screenshot(`${ssPrefix}-admin`, { capture: 'viewport' });
   });
 
   it('/admin/app is successfully loaded', () => {
     cy.visit('/admin/app');
+    cy.getByTestid('admin-app-settings').should('be.visible');
     cy.screenshot(`${ssPrefix}-admin-app`, { capture: 'viewport' });
   });
 
   it('/admin/security is successfully loaded', () => {
     cy.visit('/admin/security');
+    cy.getByTestid('admin-security').should('be.visible');
     cy.screenshot(`${ssPrefix}-admin-security`, { capture: 'viewport' });
   });
 
   it('/admin/markdown is successfully loaded', () => {
     cy.visit('/admin/markdown');
+    cy.getByTestid('admin-markdown').should('be.visible');
     cy.screenshot(`${ssPrefix}-admin-markdown`, { capture: 'viewport' });
   });
 
   it('/admin/customize is successfully loaded', () => {
     cy.visit('/admin/customize');
+    cy.getByTestid('admin-customize').should('be.visible');
     cy.screenshot(`${ssPrefix}-admin-customize`, { capture: 'viewport' });
   });
 
   it('/admin/importer is successfully loaded', () => {
     cy.visit('/admin/importer');
+    cy.getByTestid('admin-import-data').should('be.visible');
     cy.screenshot(`${ssPrefix}-admin-importer`, { capture: 'viewport' });
   });
 
   it('/admin/export is successfully loaded', () => {
     cy.visit('/admin/export');
+    cy.getByTestid('admin-export-archive-data').should('be.visible');
     cy.screenshot(`${ssPrefix}-admin-export`, { capture: 'viewport' });
   });
 
   it('/admin/notification is successfully loaded', () => {
     cy.visit('/admin/notification');
+    cy.getByTestid('admin-notification').should('be.visible');
     cy.screenshot(`${ssPrefix}-admin-notification`, { capture: 'viewport' });
   });
 
   it('/admin/slack-integration is successfully loaded', () => {
     cy.visit('/admin/slack-integration');
+    cy.getByTestid('admin-slack-integration').should('be.visible');
     cy.screenshot(`${ssPrefix}-admin-slack-integration`, { capture: 'viewport' });
   });
 
   it('/admin/slack-integration-legacy is successfully loaded', () => {
     cy.visit('/admin/slack-integration-legacy');
+    cy.getByTestid('admin-slack-integration-legacy').should('be.visible');
     cy.screenshot(`${ssPrefix}-admin-slack-integration-legacy`, { capture: 'viewport' });
   });
 
   it('/admin/users is successfully loaded', () => {
     cy.visit('/admin/users');
+    cy.getByTestid('admin-users').should('be.visible');
     cy.screenshot(`${ssPrefix}-admin-users`, { capture: 'viewport' });
   });
 
   it('/admin/user-groups is successfully loaded', () => {
     cy.visit('/admin/user-groups');
+    cy.getByTestid('admin-user-groups').should('be.visible');
     cy.screenshot(`${ssPrefix}-admin-user-groups`, { capture: 'viewport' });
   });
 
   it('/admin/search is successfully loaded', () => {
     cy.visit('/admin/search');
+    cy.getByTestid('admin-full-text-search').should('be.visible');
     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' });
   });
 
+  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' });
   });
 
-  it('/trash is successfully loaded', () => {
-    cy.visit('/trash', {  });
-    cy.screenshot(`${ssPrefix}-trash`, { capture: 'viewport' });
-  });
-
   it('/Sandbox/Math is successfully loaded', () => {
     cy.visit('/Sandbox/Math');
     cy.screenshot(`${ssPrefix}-sandbox-math`, { capture: 'viewport' });
@@ -50,14 +45,4 @@ context('Access to page', () => {
     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', () => {
-    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' });
   });
 
   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.screenshot(`${ssPrefix}-the-first-checkbox-on`, { capture: 'viewport' });