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

Merge branch 'imprv/page-v5-test-code-rename' into imprv/page-v5-test-code-duplication

yohei0125 4 лет назад
Родитель
Сommit
0aaeefc180
100 измененных файлов с 1830 добавлено и 1218 удалено
  1. 14 11
      .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. 3 3
      packages/app/src/client/app.jsx
  11. 13 0
      packages/app/src/client/interfaces/selectable-all.ts
  12. 12 0
      packages/app/src/client/services/ContextExtractor.tsx
  13. 2 2
      packages/app/src/components/Admin/AdminHome/AdminHome.jsx
  14. 2 2
      packages/app/src/components/Admin/App/AppSettingsPageContents.jsx
  15. 2 2
      packages/app/src/components/Admin/Customize/Customize.jsx
  16. 2 2
      packages/app/src/components/Admin/ExportArchiveDataPage.jsx
  17. 2 2
      packages/app/src/components/Admin/FullTextSearchManagement.jsx
  18. 2 2
      packages/app/src/components/Admin/ImportData/ImportDataPageContents.jsx
  19. 2 2
      packages/app/src/components/Admin/LegacySlackIntegration/LegacySlackIntegration.jsx
  20. 2 2
      packages/app/src/components/Admin/MarkdownSetting/MarkDownSettingContents.jsx
  21. 2 2
      packages/app/src/components/Admin/Notification/NotificationSetting.jsx
  22. 2 2
      packages/app/src/components/Admin/Security/SecurityManagementContents.jsx
  23. 2 2
      packages/app/src/components/Admin/SlackIntegration/SlackIntegration.jsx
  24. 0 3
      packages/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx
  25. 12 27
      packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx
  26. 37 36
      packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx
  27. 5 7
      packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  28. 84 32
      packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  29. 2 2
      packages/app/src/components/Admin/UserManagement.jsx
  30. 24 12
      packages/app/src/components/DescendantsPageList.tsx
  31. 1 1
      packages/app/src/components/DescendantsPageListModal.tsx
  32. 2 2
      packages/app/src/components/ForbiddenPage.tsx
  33. 0 2
      packages/app/src/components/IdenticalPathPage.tsx
  34. 1 0
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  35. 1 0
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  36. 7 1
      packages/app/src/components/Navbar/GrowiSubNavigation.tsx
  37. 6 1
      packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.jsx
  38. 3 1
      packages/app/src/components/Navbar/SubNavButtons.tsx
  39. 4 13
      packages/app/src/components/NotFoundPage.tsx
  40. 1 1
      packages/app/src/components/Page/DisplaySwitcher.tsx
  41. 1 1
      packages/app/src/components/Page/RevisionLoader.jsx
  42. 10 5
      packages/app/src/components/Page/RevisionRenderer.jsx
  43. 14 3
      packages/app/src/components/PageCreateModal.jsx
  44. 5 16
      packages/app/src/components/PageDeleteModal.tsx
  45. 48 17
      packages/app/src/components/PageList/PageListItemL.tsx
  46. 1 1
      packages/app/src/components/PaginationWrapper.tsx
  47. 3 0
      packages/app/src/components/SearchForm.tsx
  48. 0 396
      packages/app/src/components/SearchPage.jsx
  49. 293 0
      packages/app/src/components/SearchPage.tsx
  50. 0 64
      packages/app/src/components/SearchPage/DeleteSelectedPageGroup.tsx
  51. 0 42
      packages/app/src/components/SearchPage/IncludeSpecificPathButton.jsx
  52. 74 0
      packages/app/src/components/SearchPage/OperateAllControl.tsx
  53. 77 92
      packages/app/src/components/SearchPage/SearchControl.tsx
  54. 22 23
      packages/app/src/components/SearchPage/SearchOptionModal.tsx
  55. 0 71
      packages/app/src/components/SearchPage/SearchPageForm.jsx
  56. 0 79
      packages/app/src/components/SearchPage/SearchPageLayout.tsx
  57. 19 16
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  58. 50 40
      packages/app/src/components/SearchPage/SearchResultList.tsx
  59. 10 8
      packages/app/src/components/SearchPage/SortControl.tsx
  60. 180 0
      packages/app/src/components/SearchPage2/SearchPageBase.tsx
  61. 5 4
      packages/app/src/components/SearchTypeahead.tsx
  62. 15 7
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  63. 3 3
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  64. 3 3
      packages/app/src/components/TrashPageList.jsx
  65. 9 13
      packages/app/src/interfaces/search.ts
  66. 1 0
      packages/app/src/interfaces/user-group-response.ts
  67. 2 1
      packages/app/src/server/crowi/index.js
  68. 2 12
      packages/app/src/server/interfaces/search.ts
  69. 1 1
      packages/app/src/server/middlewares/auto-reconnect-to-search.js
  70. 1 1
      packages/app/src/server/models/page.ts
  71. 0 2
      packages/app/src/server/routes/admin.js
  72. 1 1
      packages/app/src/server/routes/index.js
  73. 0 10
      packages/app/src/server/routes/page.js
  74. 4 3
      packages/app/src/server/routes/search.js
  75. 11 2
      packages/app/src/server/service/config-loader.ts
  76. 27 5
      packages/app/src/server/service/installer.ts
  77. 40 17
      packages/app/src/server/service/search-delegator/elasticsearch.ts
  78. 4 2
      packages/app/src/server/service/search-delegator/private-legacy-pages.ts
  79. 4 6
      packages/app/src/server/service/search.ts
  80. 2 2
      packages/app/src/server/service/user-group.ts
  81. 2 2
      packages/app/src/server/views/layout-growi/page_list.html
  82. 1 1
      packages/app/src/server/views/tags.html
  83. 12 0
      packages/app/src/stores/context.tsx
  84. 25 3
      packages/app/src/stores/modal.tsx
  85. 92 0
      packages/app/src/stores/search.tsx
  86. 4 7
      packages/app/src/stores/user-group.tsx
  87. 12 27
      packages/app/src/styles/_search.scss
  88. 9 4
      packages/app/src/styles/_subnav.scss
  89. 4 0
      packages/app/src/styles/atoms/_custom_control.scss
  90. 1 1
      packages/app/src/styles/theme/_apply-colors.scss
  91. 0 0
      packages/app/test/cypress/integration/0-advanced-examples/misc.spec.ts
  92. 0 0
      packages/app/test/cypress/integration/0-advanced-examples/viewport.spec.ts
  93. 116 0
      packages/app/test/cypress/integration/2-basic-features/access-to-admin-page.spec.ts
  94. 33 0
      packages/app/test/cypress/integration/2-basic-features/access-to-me-page.spec.ts
  95. 16 1
      packages/app/test/cypress/integration/2-basic-features/access-to-page.spec.ts
  96. 35 0
      packages/app/test/cypress/integration/2-basic-features/access-to-special-page.spec.ts
  97. 37 0
      packages/app/test/cypress/integration/2-basic-features/open-page-create-modal.spec.ts
  98. 53 0
      packages/app/test/cypress/integration/3-search/access-to-result-page-directly.spec.ts
  99. 10 6
      packages/app/test/integration/service/page-grant.test.js
  100. 6 1
      packages/app/test/integration/service/v5-migration.test.js

+ 14 - 11
.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
@@ -187,13 +183,19 @@ jobs:
       fail-fast: false
       matrix:
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
-        spec-group: ['1', '2']
+        spec-group: ['1', '2', '3']
 
     services:
       mongodb:
         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) {

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

@@ -13,7 +13,7 @@ import { swrGlobalConfiguration } from '~/utils/swr-utils';
 import InAppNotificationPage from '../components/InAppNotification/InAppNotificationPage';
 import ErrorBoundary from '../components/ErrorBoudary';
 import Sidebar from '../components/Sidebar';
-import SearchPage from '../components/SearchPage';
+import { SearchPage } from '../components/SearchPage';
 import TagsList from '../components/TagsList';
 import DisplaySwitcher from '../components/Page/DisplaySwitcher';
 import { defaultEditorOptions, defaultPreviewOptions } from '../components/PageEditor/OptionsSelector';
@@ -85,7 +85,7 @@ logger.info('unstated containers have been initialized');
 Object.assign(componentMappings, {
   'grw-sidebar-wrapper': <Sidebar />,
 
-  'search-page': <SearchPage crowi={appContainer} />,
+  'search-page': <SearchPage appContainer={appContainer} />,
   'all-in-app-notifications': <InAppNotificationPage />,
   'identical-path-page': <IdenticalPathPage />,
 
@@ -96,7 +96,7 @@ Object.assign(componentMappings, {
 
   'trash-page-alert': <TrashPageAlert />,
 
-  'trash-page-list': <TrashPageList />,
+  'trash-page-list-container': <TrashPageList />,
 
   'not-found-page': <NotFoundPage />,
 

+ 13 - 0
packages/app/src/client/interfaces/selectable-all.ts

@@ -0,0 +1,13 @@
+export interface ISelectable {
+  select: () => void,
+  deselect: () => void,
+}
+
+export interface ISelectableAndIndeterminatable extends ISelectable {
+  setIndeterminate: () => void,
+}
+
+export interface ISelectableAll {
+  selectAll: () => void,
+  deselectAll: () => void,
+}

+ 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 - 2
packages/app/src/components/IdenticalPathPage.tsx

@@ -109,10 +109,8 @@ const IdenticalPathPage:FC<IdenticalPathPageProps> = (props: IdenticalPathPagePr
                   key={pageId}
                   page={pageWithMeta}
                   isSelected={false}
-                  isChecked={false}
                   isEnableActions
                   showPageUpdatedTime
-                // Todo: add onClickDeleteButton when delete feature implemented
                 />
               );
             })}

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

@@ -282,6 +282,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>

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

@@ -126,7 +126,7 @@ LegacyRevisionLoader.propTypes = {
   revisionId: PropTypes.string.isRequired,
   lazy: PropTypes.bool,
   onRevisionLoaded: PropTypes.func,
-  highlightKeywords: PropTypes.string,
+  highlightKeywords: PropTypes.arrayOf(PropTypes.string),
 };
 
 const RevisionLoader = (props) => {

+ 10 - 5
packages/app/src/components/Page/RevisionRenderer.jsx

@@ -62,13 +62,18 @@ class LegacyRevisionRenderer extends React.PureComponent {
    * @param {string} body html strings
    * @param {string} keywords
    */
-  getHighlightedBody(body, keywords) {
+  getHighlightedBody(body, _keywords) {
+    const keywords = Array.isArray(_keywords)
+      ? _keywords
+      : [_keywords];
+
     const normalizedKeywordsArray = [];
     // !!TODO!!: add test code refs: https://redmine.weseek.co.jp/issues/86841
     // Separate keywords
     // - Surrounded by double quotation
     // - Split by both full-width and half-width spaces
-    [...keywords.match(/"[^"]+"|[^\u{20}\u{3000}]+/ug)].forEach((keyword, i) => {
+    // [...keywords.match(/"[^"]+"|[^\u{20}\u{3000}]+/ug)].forEach((keyword, i) => {
+    keywords.forEach((keyword, i) => {
       if (keyword === '') {
         return;
       }
@@ -138,7 +143,7 @@ class LegacyRevisionRenderer extends React.PureComponent {
     await interceptorManager.process('prePostProcess', context);
     context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
 
-    if (highlightKeywords != null) {
+    if (highlightKeywords != null && highlightKeywords.length > 0) {
       context.parsedHTML = this.getHighlightedBody(context.parsedHTML, highlightKeywords);
     }
     await interceptorManager.process('postPostProcess', context);
@@ -167,7 +172,7 @@ LegacyRevisionRenderer.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   markdown: PropTypes.string.isRequired,
-  highlightKeywords: PropTypes.string,
+  highlightKeywords: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]),
   additionalClassName: PropTypes.string,
 };
 
@@ -185,7 +190,7 @@ const RevisionRenderer = (props) => {
 RevisionRenderer.propTypes = {
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   markdown: PropTypes.string.isRequired,
-  highlightKeywords: PropTypes.string,
+  highlightKeywords: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]),
   additionalClassName: PropTypes.string,
 };
 

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

+ 5 - 16
packages/app/src/components/PageDeleteModal.tsx

@@ -26,21 +26,14 @@ const deleteIconAndKey = {
   },
 };
 
-type Props = {
-  isDeleteCompletelyModal: boolean,
-  isAbleToDeleteCompletely: boolean,
-  onClose?: () => void,
-}
-
-const PageDeleteModal: FC<Props> = (props: Props) => {
-  const { t } = useTranslation('');
-  const {
-    isDeleteCompletelyModal, isAbleToDeleteCompletely,
-  } = props;
+const PageDeleteModal: FC = () => {
+  const { t } = useTranslation();
 
   const { data: deleteModalData, close: closeDeleteModal } = usePageDeleteModal();
 
   const isOpened = deleteModalData?.isOpened ?? false;
+  const isAbleToDeleteCompletely = deleteModalData?.isAbleToDeleteCompletely ?? false;
+  const isDeleteCompletelyModal = deleteModalData?.isDeleteCompletelyModal ?? false;
 
   const [isDeleteRecursively, setIsDeleteRecursively] = useState(true);
   const [isDeleteCompletely, setIsDeleteCompletely] = useState(isDeleteCompletelyModal && isAbleToDeleteCompletely);
@@ -143,7 +136,6 @@ const PageDeleteModal: FC<Props> = (props: Props) => {
   // DeleteCompletely is currently disabled
   // TODO1 : Retrive isAbleToDeleteCompleltly state everywhere in the system via swr.
   // Story: https://redmine.weseek.co.jp/issues/82222
-
   // TODO2 : use toaster
   // TASK : https://redmine.weseek.co.jp/issues/82299
   function renderDeleteCompletelyForm() {
@@ -154,13 +146,10 @@ const PageDeleteModal: FC<Props> = (props: Props) => {
           name="completely"
           id="deleteCompletely"
           type="checkbox"
-          // disabled={!isAbleToDeleteCompletely}
-          // disabled // Todo: will be implemented at https://redmine.weseek.co.jp/issues/82222
+          disabled={!isAbleToDeleteCompletely}
           checked={isDeleteCompletely}
           onChange={changeIsDeleteCompletelyHandler}
         />
-        {/* ↓↓ undo this comment out at https://redmine.weseek.co.jp/issues/82222 ↓↓ */}
-        {/* <label className="custom-control-label text-danger" htmlFor="deleteCompletely"> */}
         <label className="custom-control-label" htmlFor="deleteCompletely">
           { t('modal_delete.delete_completely')}
           <p className="form-text text-muted mt-0"> { t('modal_delete.completely') }</p>

+ 48 - 17
packages/app/src/components/PageList/PageListItemL.tsx

@@ -1,4 +1,9 @@
-import React, { memo, useCallback } from 'react';
+import React, {
+  forwardRef,
+  ForwardRefRenderFunction, memo, useCallback, useImperativeHandle, useRef,
+} from 'react';
+
+import { CustomInput } from 'reactstrap';
 
 import Clamp from 'react-multiline-clamp';
 import { format } from 'date-fns';
@@ -7,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';
@@ -16,28 +21,47 @@ import { IPageSearchMeta, isIPageSearchMeta } from '~/interfaces/search';
 import { PageItemControl } from '../Common/Dropdown/PageItemControl';
 import LinkedPagePath from '~/models/linked-page-path';
 import PagePathHierarchicalLink from '../PagePathHierarchicalLink';
+import { ISelectable } from '~/client/interfaces/selectable-all';
 
 type Props = {
   page: IPageWithMeta | IPageWithMeta<IPageInfoAll & IPageSearchMeta>,
   isSelected?: boolean, // is item selected(focused)
-  isChecked?: boolean, // is checkbox of item checked
   isEnableActions?: boolean,
   showPageUpdatedTime?: boolean, // whether to show page's updated time at the top-right corner of item
-  onClickCheckbox?: (pageId: string) => void,
+  onCheckboxChanged?: (isChecked: boolean, pageId: string) => void,
   onClickItem?: (pageId: string) => void,
-  onClickDeleteButton?: (pageId: string) => void,
 }
 
-export const PageListItemL = memo((props: Props): JSX.Element => {
+const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (props: Props, ref): JSX.Element => {
   const {
     // todo: refactoring variable name to clear what changed
-    page: { pageData, pageMeta }, isSelected, onClickItem, onClickCheckbox, isChecked, isEnableActions,
+    page: { pageData, pageMeta }, isSelected, isEnableActions,
     showPageUpdatedTime,
+    onClickItem, onCheckboxChanged,
   } = props;
 
+  const inputRef = useRef<HTMLInputElement>(null);
+
+  // publish ISelectable methods
+  useImperativeHandle(ref, () => ({
+    select: () => {
+      const input = inputRef.current;
+      if (input != null) {
+        input.checked = true;
+      }
+    },
+    deselect: () => {
+      const input = inputRef.current;
+      if (input != null) {
+        input.checked = false;
+      }
+    },
+  }));
+
   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;
@@ -70,7 +94,12 @@ export const PageListItemL = memo((props: Props): JSX.Element => {
     openRenameModal(pageId, revisionId as string, path);
   }, [openRenameModal, pageData]);
 
-  const styleListGroupItem = (!isDeviceSmallerThanLg && onClickCheckbox != null) ? 'list-group-item-action' : '';
+  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' : '';
 
@@ -85,14 +114,14 @@ export const PageListItemL = memo((props: Props): JSX.Element => {
       >
         <div className="d-flex">
           {/* checkbox */}
-          {onClickCheckbox != null && (
-            <div className="form-check d-flex align-items-center justify-content-center px-md-2 pl-3 pr-2 search-item-checkbox">
-              <input
-                className="form-check-input position-relative m-0"
+          {onCheckboxChanged != null && (
+            <div className="d-flex align-items-center justify-content-center pl-md-2 pl-3">
+              <CustomInput
                 type="checkbox"
-                id="flexCheckDefault"
-                onChange={() => { onClickCheckbox(pageData._id) }}
-                checked={isChecked}
+                id={`cbSelect-${pageData._id}`}
+                data-testid="cb-select"
+                innerRef={inputRef}
+                onChange={(e) => { onCheckboxChanged(e.target.checked, pageData._id) }}
               />
             </div>
           )}
@@ -132,7 +161,7 @@ export const PageListItemL = memo((props: Props): JSX.Element => {
                 <PageItemControl
                   pageId={pageData._id}
                   pageInfo={pageMeta}
-                  onClickDeleteMenuItem={props.onClickDeleteButton}
+                  onClickDeleteMenuItem={deleteMenuItemClickHandler}
                   onClickRenameMenuItem={renameMenuItemClickHandler}
                   isEnableActions={isEnableActions}
                   onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
@@ -156,4 +185,6 @@ export const PageListItemL = memo((props: Props): JSX.Element => {
       </div>
     </li>
   );
-});
+};
+
+export const PageListItemL = memo(forwardRef(PageListItemLSubstance));

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

@@ -7,7 +7,7 @@ import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
 
 type Props = {
   activePage: number,
-  changePage?: (number) => void,
+  changePage?: (activePage: number) => void,
   totalItemsCount: number,
   pagingLimit?: number,
   align?: string,

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

+ 0 - 396
packages/app/src/components/SearchPage.jsx

@@ -1,396 +0,0 @@
-// This is the root component for #search-page
-
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-import {
-  DetachCodeBlockInterceptor,
-  RestoreCodeBlockInterceptor,
-} from '../client/util/interceptor/detach-code-blocks';
-
-import { withUnstatedContainers } from './UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import { toastError } from '~/client/util/apiNotification';
-import SearchPageLayout from './SearchPage/SearchPageLayout';
-import SearchResultContent from './SearchPage/SearchResultContent';
-import SearchResultList from './SearchPage/SearchResultList';
-import SearchControl from './SearchPage/SearchControl';
-import { CheckboxType, SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
-import PageDeleteModal from './PageDeleteModal';
-import { useIsGuestUser } from '~/stores/context';
-
-export const specificPathNames = {
-  user: '/user',
-  trash: '/trash',
-};
-class SearchPage extends React.Component {
-
-  constructor(props) {
-    super(props);
-    // NOTE : selectedPages is deletion related state, will be used later in story 77535, 77565.
-    // deletionModal, deletion related functions are all removed, add them back when necessary.
-    // i.e ) in story 77525 or any tasks implementing deletion functionalities
-    this.state = {
-      searchingKeyword: decodeURI(this.props.query.q) || '',
-      searchedKeyword: '',
-      searchResults: [],
-      searchResultMeta: {},
-      focusedSearchResultData: null,
-      selectedPagesIdList: new Set(),
-      searchResultCount: 0,
-      activePage: 1,
-      pagingLimit: this.props.appContainer.config.pageLimitationL || 50,
-      excludeUserPages: true,
-      excludeTrashPages: true,
-      sort: SORT_AXIS.RELATION_SCORE,
-      order: SORT_ORDER.DESC,
-      selectAllCheckboxType: CheckboxType.NONE_CHECKED,
-      isDeleteConfirmModalShown: false,
-      deleteTargetPageIds: new Set(),
-    };
-
-    // TODO: Move this code to the right place after completing the "omit unstated" initiative.
-    const { interceptorManager } = props.appContainer;
-    interceptorManager.addInterceptor(new DetachCodeBlockInterceptor(props.appContainer), 10); // process as soon as possible
-    interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(props.appContainer), 900); // process as late as possible
-
-    this.changeURL = this.changeURL.bind(this);
-    this.search = this.search.bind(this);
-    this.onSearchInvoked = this.onSearchInvoked.bind(this);
-    this.selectPage = this.selectPage.bind(this);
-    this.toggleCheckBox = this.toggleCheckBox.bind(this);
-    this.switchExcludeUserPagesHandler = this.switchExcludeUserPagesHandler.bind(this);
-    this.switchExcludeTrashPagesHandler = this.switchExcludeTrashPagesHandler.bind(this);
-    this.onChangeSortInvoked = this.onChangeSortInvoked.bind(this);
-    this.onPagingNumberChanged = this.onPagingNumberChanged.bind(this);
-    this.onPagingLimitChanged = this.onPagingLimitChanged.bind(this);
-    this.deleteSinglePageButtonHandler = this.deleteSinglePageButtonHandler.bind(this);
-    this.deleteAllPagesButtonHandler = this.deleteAllPagesButtonHandler.bind(this);
-    this.closeDeleteConfirmModalHandler = this.closeDeleteConfirmModalHandler.bind(this);
-  }
-
-  componentDidMount() {
-    const keyword = this.state.searchingKeyword;
-    if (keyword !== '') {
-      this.search({ keyword });
-    }
-  }
-
-  static getQueryByLocation(location) {
-    const search = location.search || '';
-    const query = {};
-
-    search.replace(/^\?/, '').split('&').forEach((element) => {
-      const queryParts = element.split('=');
-      query[queryParts[0]] = decodeURIComponent(queryParts[1]).replace(/\+/g, ' ');
-    });
-
-    return query;
-  }
-
-  switchExcludeUserPagesHandler() {
-    this.setState({ excludeUserPages: !this.state.excludeUserPages });
-  }
-
-  switchExcludeTrashPagesHandler() {
-    this.setState({ excludeTrashPages: !this.state.excludeTrashPages });
-  }
-
-  onChangeSortInvoked(nextSort, nextOrder) {
-    this.setState({
-      sort: nextSort,
-      order: nextOrder,
-    });
-  }
-
-  changeURL(keyword, refreshHash) {
-    let hash = window.location.hash || '';
-    // TODO 整理する
-    if (refreshHash || this.state.searchedKeyword !== '') {
-      hash = '';
-    }
-    if (window.history && window.history.pushState) {
-      window.history.pushState('', `Search - ${keyword}`, `/_search?q=${keyword}${hash}`);
-    }
-  }
-
-  createSearchQuery(keyword) {
-    let query = keyword;
-
-    // pages included in specific path are not retrived when prefix is added
-    if (this.state.excludeTrashPages) {
-      query = `${query} -prefix:${specificPathNames.trash}`;
-    }
-    if (this.state.excludeUserPages) {
-      query = `${query} -prefix:${specificPathNames.user}`;
-    }
-
-    return query;
-  }
-
-  /**
-   * this method is called when user changes paging number
-   */
-  async onPagingNumberChanged(activePage) {
-    this.setState({ activePage }, () => this.search({ keyword: this.state.searchedKeyword }));
-  }
-
-  /**
-   * this method is called when user searches by pressing Enter or using searchbox
-   */
-  async onSearchInvoked(data) {
-    this.setState({ activePage: 1 }, () => this.search(data));
-  }
-
-  /**
-   * change number of pages to display per page and execute search method after.
-   */
-  async onPagingLimitChanged(limit) {
-    this.setState({ pagingLimit: limit }, () => this.search({ keyword: this.state.searchedKeyword }));
-  }
-
-  // todo: refactoring
-  // refs: https://redmine.weseek.co.jp/issues/82139
-  async search(data) {
-    // reset following states when search runs
-    this.setState({
-      selectedPagesIdList: new Set(),
-      selectAllCheckboxType: CheckboxType.NONE_CHECKED,
-    });
-
-    const keyword = data.keyword;
-    if (keyword === '') {
-      this.setState({
-        searchingKeyword: '',
-        searchedKeyword: '',
-        searchResults: [],
-        searchResultMeta: {},
-        searchResultCount: 0,
-        activePage: 1,
-      });
-
-      return true;
-    }
-
-    this.setState({
-      searchingKeyword: keyword,
-    });
-    const pagingLimit = this.state.pagingLimit;
-    const offset = (this.state.activePage * pagingLimit) - pagingLimit;
-    const { sort, order } = this.state;
-    try {
-      const res = await this.props.appContainer.apiGet('/search', {
-        q: this.createSearchQuery(keyword),
-        limit: pagingLimit,
-        offset,
-        sort,
-        order,
-      });
-
-      this.changeURL(keyword);
-      if (res.data.length > 0) {
-        this.setState({
-          searchedKeyword: keyword,
-          searchResults: res.data,
-          searchResultMeta: res.meta,
-          searchResultCount: res.meta.total,
-          focusedSearchResultData: res.data[0],
-          // reset active page if keyword changes, otherwise set the current state
-          activePage: this.state.searchedKeyword === keyword ? this.state.activePage : 1,
-        });
-      }
-      else {
-        this.setState({
-          searchedKeyword: keyword,
-          searchResults: [],
-          searchResultMeta: {},
-          searchResultCount: 0,
-          focusedSearchResultData: {},
-          activePage: 1,
-        });
-      }
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  selectPage= (pageId) => {
-    const index = this.state.searchResults.findIndex(({ pageData }) => {
-      return pageData._id === pageId;
-    });
-    this.setState({
-      focusedSearchResultData: this.state.searchResults[index],
-    });
-  }
-
-  toggleCheckBox = (pageId) => {
-    const { selectedPagesIdList } = this.state;
-
-    if (selectedPagesIdList.has(pageId)) {
-      selectedPagesIdList.delete(pageId);
-    }
-    else {
-      selectedPagesIdList.add(pageId);
-    }
-    switch (selectedPagesIdList.size) {
-      case 0:
-        return this.setState({ selectAllCheckboxType: CheckboxType.NONE_CHECKED });
-      case this.state.searchResults.length:
-        return this.setState({ selectAllCheckboxType: CheckboxType.ALL_CHECKED });
-      default:
-        return this.setState({ selectAllCheckboxType: CheckboxType.INDETERMINATE });
-    }
-  }
-
-  toggleAllCheckBox = (nextSelectAllCheckboxType) => {
-    const { selectedPagesIdList, searchResults } = this.state;
-    if (nextSelectAllCheckboxType === CheckboxType.NONE_CHECKED) {
-      selectedPagesIdList.clear();
-    }
-    else {
-      searchResults.forEach((page) => {
-        selectedPagesIdList.add(page.pageData._id);
-      });
-    }
-    this.setState({
-      selectedPagesIdList,
-      selectAllCheckboxType: nextSelectAllCheckboxType,
-    });
-  };
-
-  getSelectedPagesToDelete() {
-    const filteredPages = this.state.searchResults.filter((page) => {
-      return Array.from(this.state.deleteTargetPageIds).find(id => id === page.pageData._id);
-    });
-    return filteredPages.map(page => ({
-      pageId: page.pageData._id,
-      revisionId: page.pageData.revision,
-      path: page.pageData.path,
-    }));
-  }
-
-  deleteSinglePageButtonHandler(pageId) {
-    this.setState({ deleteTargetPageIds: new Set([pageId]) });
-    this.setState({ isDeleteConfirmModalShown: true });
-  }
-
-  deleteAllPagesButtonHandler() {
-    if (this.state.selectedPagesIdList.size === 0) { return }
-    this.setState({ deleteTargetPageIds: this.state.selectedPagesIdList });
-    this.setState({ isDeleteConfirmModalShown: true });
-  }
-
-  closeDeleteConfirmModalHandler() {
-    this.setState({ isDeleteConfirmModalShown: false });
-  }
-
-  renderSearchResultContent = () => {
-    return (
-      <SearchResultContent
-        appContainer={this.props.appContainer}
-        searchingKeyword={this.state.searchingKeyword}
-        focusedSearchResultData={this.state.focusedSearchResultData}
-        showPageControlDropdown={!this.props.isGuestUser}
-      >
-      </SearchResultContent>
-    );
-  }
-
-  renderSearchResultList = () => {
-    return (
-      <SearchResultList
-        pages={this.state.searchResults || []}
-        isEnableActions={!this.props.isGuestUser}
-        focusedSearchResultData={this.state.focusedSearchResultData}
-        selectedPagesIdList={this.state.selectedPagesIdList || []}
-        searchResultCount={this.state.searchResultCount}
-        activePage={this.state.activePage}
-        pagingLimit={this.state.pagingLimit}
-        onClickItem={this.selectPage}
-        onClickCheckbox={this.toggleCheckBox}
-        onPagingNumberChanged={this.onPagingNumberChanged}
-        onClickDeleteButton={this.deleteSinglePageButtonHandler}
-      />
-    );
-  }
-
-  renderSearchControl = () => {
-    return (
-      <SearchControl
-        searchingKeyword={this.state.searchingKeyword}
-        sort={this.state.sort}
-        order={this.state.order}
-        searchResultCount={this.state.searchResultCount || 0}
-        appContainer={this.props.appContainer}
-        onSearchInvoked={this.onSearchInvoked}
-        onClickSelectAllCheckbox={this.toggleAllCheckBox}
-        selectAllCheckboxType={this.state.selectAllCheckboxType}
-        onClickDeleteAllButton={this.deleteAllPagesButtonHandler}
-        onExcludeUserPagesSwitched={this.switchExcludeUserPagesHandler}
-        onExcludeTrashPagesSwitched={this.switchExcludeTrashPagesHandler}
-        excludeUserPages={this.state.excludeUserPages}
-        excludeTrashPages={this.state.excludeTrashPages}
-        onChangeSortInvoked={this.onChangeSortInvoked}
-      >
-      </SearchControl>
-    );
-  }
-
-  render() {
-    return (
-      <div>
-        <SearchPageLayout
-          SearchControl={this.renderSearchControl}
-          SearchResultList={this.renderSearchResultList}
-          SearchResultContent={this.renderSearchResultContent}
-          searchResultMeta={this.state.searchResultMeta}
-          searchingKeyword={this.state.searchedKeyword}
-          onPagingLimitChanged={this.onPagingLimitChanged}
-          pagingLimit={this.state.pagingLimit}
-          activePage={this.state.activePage}
-        >
-        </SearchPageLayout>
-        {/* TODO: show PageDeleteModal with usePageDeleteModal by 87569  */}
-        <PageDeleteModal
-          isOpen={this.state.isDeleteConfirmModalShown}
-          onClose={this.closeDeleteConfirmModalHandler}
-          pages={this.getSelectedPagesToDelete()}
-        />
-      </div>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const SearchPageHOCWrapper = withTranslation()(withUnstatedContainers(SearchPage, [AppContainer]));
-
-SearchPage.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  query: PropTypes.object,
-  isGuestUser: PropTypes.bool.isRequired,
-};
-SearchPage.defaultProps = {
-  // pollInterval: 1000,
-  query: SearchPage.getQueryByLocation(window.location || {}),
-};
-
-const SearchPageFCWrapper = (props) => {
-  const { data: isGuestUser } = useIsGuestUser();
-
-  /*
-   * dependencies
-   */
-  if (isGuestUser == null) {
-    return null;
-  }
-
-  return <SearchPageHOCWrapper {...props} isGuestUser={isGuestUser} />;
-};
-
-export default SearchPageFCWrapper;

+ 293 - 0
packages/app/src/components/SearchPage.tsx

@@ -0,0 +1,293 @@
+import React, {
+  useCallback, useEffect, useMemo, useRef, useState,
+} from 'react';
+import { useTranslation } from 'react-i18next';
+
+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';
+import { OperateAllControl } from './SearchPage/OperateAllControl';
+import SearchControl from './SearchPage/SearchControl';
+
+import { SearchPageBase } from './SearchPage2/SearchPageBase';
+
+
+// TODO: replace with "customize:showPageLimitationS"
+const INITIAL_PAGIONG_SIZE = 20;
+
+
+/**
+ * SearchResultListHead
+ */
+
+type SearchResultListHeadProps = {
+  searchResult: IFormattedSearchResult,
+  searchingKeyword: string,
+  offset: number,
+  pagingSize: number,
+  onPagingSizeChanged: (size: number) => void,
+}
+
+const SearchResultListHead = React.memo((props: SearchResultListHeadProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const {
+    searchResult, searchingKeyword, offset, pagingSize,
+    onPagingSizeChanged,
+  } = props;
+
+  const { took, total, hitsCount } = searchResult.meta;
+  const leftNum = offset + 1;
+  const rightNum = offset + hitsCount;
+
+  return (
+    <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>
+        { 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 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>
+        <select
+          defaultValue={pagingSize}
+          className="custom-select"
+          id="inputGroupSelect01"
+          onChange={e => onPagingSizeChanged(Number(e.target.value))}
+        >
+          {[20, 50, 100, 200].map((limit) => {
+            return <option key={limit} value={limit}>{limit} {t('search_result.page_number_unit')}</option>;
+          })}
+        </select>
+      </div>
+    </div>
+  );
+});
+
+
+/**
+ * SearchPage
+ */
+
+const getParsedUrlQuery = () => {
+  const search = window.location.search || '?';
+  return parseQuerystring(search.slice(1)); // remove heading '?' and parse
+};
+
+type Props = {
+  appContainer: AppContainer,
+}
+
+export const SearchPage = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  const {
+    appContainer,
+  } = props;
+
+  // parse URL Query
+  const parsedQueries = getParsedUrlQuery().q;
+  const initQ = (Array.isArray(parsedQueries) ? parsedQueries.join(' ') : parsedQueries) ?? '';
+
+  const [keyword, setKeyword] = useState<string>(initQ);
+  const [configurationsByControl, setConfigurationsByControl] = useState<Partial<ISearchConfigurations>>({});
+  const [configurationsByPagination, setConfigurationsByPagination] = useState<Partial<ISearchConfigurations>>({
+    limit: INITIAL_PAGIONG_SIZE,
+  });
+
+  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,
+    ...configurationsByPagination,
+  });
+
+  const searchInvokedHandler = useCallback((_keyword: string, newConfigurations: Partial<ISearchConfigurations>) => {
+    setKeyword(_keyword);
+    setConfigurationsByControl(newConfigurations);
+  }, []);
+
+  const selectAllCheckboxChangedHandler = useCallback((isChecked: boolean) => {
+    const instance = searchPageBaseRef.current;
+
+    if (instance == null) {
+      return;
+    }
+
+    if (isChecked) {
+      instance.selectAll();
+    }
+    else {
+      instance.deselectAll();
+    }
+  }, []);
+
+  const selectedPagesByCheckboxesChangedHandler = useCallback((selectedCount: number, totalCount: number) => {
+    const instance = selectAllControlRef.current;
+
+    if (instance == null) {
+      return;
+    }
+
+    if (selectedCount === 0) {
+      instance.deselect();
+    }
+    else if (selectedCount === totalCount) {
+      instance.select();
+    }
+    else {
+      instance.setIndeterminate();
+    }
+  }, []);
+
+  const pagingNumberChangedHandler = useCallback((activePage: number) => {
+    const currentLimit = configurationsByPagination.limit ?? INITIAL_PAGIONG_SIZE;
+    setConfigurationsByPagination({
+      ...configurationsByPagination,
+      offset: (activePage - 1) * currentLimit,
+    });
+  }, [configurationsByPagination]);
+
+  const initialSearchConditions: Partial<ISearchConditions> = useMemo(() => {
+    return {
+      keyword: initQ,
+      limit: INITIAL_PAGIONG_SIZE,
+    };
+  }, [initQ]);
+
+  // push state
+  useEffect(() => {
+    const newUrl = new URL('/_search', 'http://example.com');
+    newUrl.searchParams.append('q', keyword);
+    window.history.pushState('', `Search - ${keyword}`, `${newUrl.pathname}${newUrl.search}`);
+  }, [keyword]);
+  const hitsCount = data?.meta.hitsCount;
+
+  const { offset, limit } = conditions;
+
+  const deleteAllControl = useMemo(() => {
+    const isDisabled = hitsCount === 0;
+
+    return (
+      <OperateAllControl
+        ref={selectAllControlRef}
+        isCheckboxDisabled={isDisabled}
+        onCheckboxChanged={selectAllCheckboxChangedHandler}
+      >
+        <button
+          type="button"
+          className="btn btn-outline-danger border-0 px-2"
+          disabled={isDisabled}
+          onClick={() => null /* TODO implement */}
+        >
+          <i className="icon-fw icon-trash"></i>
+          {t('search_result.delete_all_selected_page')}
+        </button>
+      </OperateAllControl>
+    );
+  }, [hitsCount, selectAllCheckboxChangedHandler, t]);
+
+  const searchControl = useMemo(() => {
+    if (!isSearchServiceReachable) {
+      return <></>;
+    }
+    return (
+      <SearchControl
+        isSearchServiceReachable={isSearchServiceReachable}
+        initialSearchConditions={initialSearchConditions}
+        onSearchInvoked={searchInvokedHandler}
+        deleteAllControl={deleteAllControl}
+      >
+      </SearchControl>
+    );
+  }, [deleteAllControl, initialSearchConditions, isSearchServiceReachable, searchInvokedHandler]);
+
+  const searchResultListHead = useMemo(() => {
+    if (data == null) {
+      return <></>;
+    }
+    return (
+      <SearchResultListHead
+        searchResult={data}
+        searchingKeyword={keyword}
+        offset={offset}
+        pagingSize={limit}
+        onPagingSizeChanged={() => {}}
+      />
+    );
+  }, [data, keyword, limit, offset]);
+
+  const searchPager = useMemo(() => {
+    // when pager is not needed
+    if (data == null || data.meta.hitsCount === data.meta.total) {
+      return <></>;
+    }
+
+    const { total } = data.meta;
+    const { offset, limit } = conditions;
+
+    return (
+      <PaginationWrapper
+        activePage={Math.floor(offset / limit) + 1}
+        totalItemsCount={total}
+        pagingLimit={configurationsByPagination?.limit}
+        changePage={pagingNumberChangedHandler}
+      />
+    );
+  }, [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}
+      appContainer={appContainer}
+      pages={data?.data}
+      onSelectedPagesByCheckboxesChanged={selectedPagesByCheckboxesChangedHandler}
+      // Components
+      searchControl={searchControl}
+      searchResultListHead={searchResultListHead}
+      searchPager={searchPager}
+    />
+  );
+};

+ 0 - 64
packages/app/src/components/SearchPage/DeleteSelectedPageGroup.tsx

@@ -1,64 +0,0 @@
-import React, { FC, useEffect, useRef } from 'react';
-import { useTranslation } from 'react-i18next';
-import { IndeterminateInputElement } from '~/interfaces/indeterminate-input-elm';
-import { CheckboxType } from '../../interfaces/search';
-
-type Props = {
-  isSelectAllCheckboxDisabled: boolean,
-  selectAllCheckboxType: CheckboxType,
-  onClickDeleteAllButton?: () => void,
-  onClickSelectAllCheckbox?: (nextSelectAllCheckboxType: CheckboxType) => void,
-}
-
-const DeleteSelectedPageGroup:FC<Props> = (props:Props) => {
-  const { t } = useTranslation();
-  const {
-    onClickDeleteAllButton, onClickSelectAllCheckbox, selectAllCheckboxType,
-  } = props;
-
-  const onClickCheckbox = () => {
-    if (onClickSelectAllCheckbox != null) {
-      const next = selectAllCheckboxType === CheckboxType.ALL_CHECKED ? CheckboxType.NONE_CHECKED : CheckboxType.ALL_CHECKED;
-      onClickSelectAllCheckbox(next);
-    }
-  };
-
-  const onClickDeleteButton = () => {
-    if (onClickDeleteAllButton != null) { onClickDeleteAllButton() }
-  };
-
-  const selectAllCheckboxElm = useRef<IndeterminateInputElement>(null);
-  useEffect(() => {
-    if (selectAllCheckboxElm.current != null) {
-      selectAllCheckboxElm.current.indeterminate = selectAllCheckboxType === CheckboxType.INDETERMINATE;
-    }
-  }, [selectAllCheckboxType]);
-
-  return (
-
-    <div className="d-flex align-items-center">
-      <input
-        id="check-all-pages"
-        type="checkbox"
-        name="check-all-pages"
-        className="grw-indeterminate-checkbox"
-        ref={selectAllCheckboxElm}
-        disabled={props.isSelectAllCheckboxDisabled}
-        onClick={onClickCheckbox}
-        checked={selectAllCheckboxType === CheckboxType.ALL_CHECKED}
-      />
-      <button
-        type="button"
-        className="btn text-danger font-weight-light p-0 ml-2"
-        disabled={selectAllCheckboxType === CheckboxType.NONE_CHECKED}
-        onClick={onClickDeleteButton}
-      >
-        <i className="icon-trash"></i>
-        {t('search_result.delete_all_selected_page')}
-      </button>
-    </div>
-  );
-
-};
-
-export default DeleteSelectedPageGroup;

+ 0 - 42
packages/app/src/components/SearchPage/IncludeSpecificPathButton.jsx

@@ -1,42 +0,0 @@
-import React, { useState } from 'react';
-import PropTypes from 'prop-types';
-import { useTranslation } from 'react-i18next';
-
-const IncludeSpecificPathButton = (props) => {
-  const { pathToInclude, checked } = props;
-  const { t } = useTranslation();
-
-  // TODO : implement this function
-  // 77526 story https://estoc.weseek.co.jp/redmine/issues/77526
-  // 77535 stroy https://estoc.weseek.co.jp/redmine/issues/77535
-  function includeSpecificPathInSearchResult(pathToInclude) {
-    console.log(`now including ${pathToInclude} in search result`);
-  }
-  return (
-    <div className="border px-2 btn btn-outline-secondary">
-      <label className="mb-0">
-        <span className="font-weight-light">
-          {pathToInclude === '/user'
-            ? t('search_result.include_certain_path', { pathToInclude: '/user' }) : t('search_result.include_certain_path', { pathToInclude: '/trash' })}
-        </span>
-        <input
-          type="checkbox"
-          name="check-include-specific-path"
-          onChange={() => {
-            if (checked) {
-              includeSpecificPathInSearchResult(pathToInclude);
-            }
-          }}
-        />
-      </label>
-    </div>
-  );
-
-};
-
-IncludeSpecificPathButton.propTypes = {
-  pathToInclude: PropTypes.string.isRequired,
-  checked: PropTypes.bool.isRequired,
-};
-
-export default IncludeSpecificPathButton;

+ 74 - 0
packages/app/src/components/SearchPage/OperateAllControl.tsx

@@ -0,0 +1,74 @@
+import React, {
+  ChangeEvent, forwardRef, ForwardRefRenderFunction, useImperativeHandle, useRef,
+} from 'react';
+import { CustomInput } from 'reactstrap';
+import { ISelectableAndIndeterminatable } from '~/client/interfaces/selectable-all';
+import { IndeterminateInputElement } from '~/interfaces/indeterminate-input-elm';
+
+type Props = {
+  isCheckboxDisabled?: boolean,
+  onCheckboxChanged?: (isChecked: boolean) => void,
+
+  children?: React.ReactNode,
+}
+
+const OperateAllControlSubstance: ForwardRefRenderFunction<ISelectableAndIndeterminatable, Props> = (props: Props, ref): JSX.Element => {
+  const {
+    isCheckboxDisabled,
+    onCheckboxChanged,
+
+    children,
+  } = props;
+
+  const selectAllCheckboxElm = useRef<IndeterminateInputElement>(null);
+
+  // publish ISelectable methods
+  useImperativeHandle(ref, () => ({
+    select: () => {
+      const input = selectAllCheckboxElm.current;
+      if (input != null) {
+        input.checked = true;
+        input.indeterminate = false;
+      }
+    },
+    deselect: () => {
+      const input = selectAllCheckboxElm.current;
+      if (input != null) {
+        input.checked = false;
+        input.indeterminate = false;
+      }
+    },
+    setIndeterminate: () => {
+      const input = selectAllCheckboxElm.current;
+      if (input != null) {
+        input.indeterminate = true;
+      }
+    },
+  }));
+
+  const checkboxChangedHandler = (e: ChangeEvent<HTMLInputElement>) => {
+    if (onCheckboxChanged != null) {
+      onCheckboxChanged(e.target.checked);
+    }
+  };
+
+  return (
+
+    <div className="d-flex align-items-center">
+      <CustomInput
+        type="checkbox"
+        id="cb-check-all"
+        data-testid="cb-select-all"
+        innerRef={selectAllCheckboxElm}
+        disabled={isCheckboxDisabled}
+        onChange={checkboxChangedHandler}
+      />
+      <span className="ml-2">
+        {children}
+      </span>
+    </div>
+  );
+
+};
+
+export const OperateAllControl = React.memo(forwardRef(OperateAllControlSubstance));

+ 77 - 92
packages/app/src/components/SearchPage/SearchControl.tsx

@@ -1,131 +1,105 @@
-import React, { FC, useState } from 'react';
+import React, {
+  FC, useCallback, useEffect, useState,
+} from 'react';
 import { useTranslation } from 'react-i18next';
-import SearchPageForm from './SearchPageForm';
-import AppContainer from '../../client/services/AppContainer';
-import DeleteSelectedPageGroup from './DeleteSelectedPageGroup';
+
+import { SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
+import { ISearchConditions, ISearchConfigurations } from '~/stores/search';
+
 import SearchOptionModal from './SearchOptionModal';
 import SortControl from './SortControl';
-import { CheckboxType, SORT_AXIS, SORT_ORDER } from '../../interfaces/search';
+import SearchForm from '../SearchForm';
 
 type Props = {
-  searchingKeyword: string,
-  sort: SORT_AXIS,
-  order: SORT_ORDER,
-  appContainer: AppContainer,
-  searchResultCount: number,
-  selectAllCheckboxType: CheckboxType,
-  onClickDeleteAllButton?: () => void
-  onClickSelectAllCheckbox?: (nextSelectAllCheckboxType: CheckboxType) => void,
-  excludeUserPages: boolean,
-  excludeTrashPages: boolean,
-  onSearchInvoked: (data: {keyword: string}) => boolean,
-  onExcludeUserPagesSwitched?: () => void,
-  onExcludeTrashPagesSwitched?: () => void,
-  onChangeSortInvoked?: (nextSort: SORT_AXIS, nextOrder: SORT_ORDER) => void,
+  isSearchServiceReachable: boolean,
+  initialSearchConditions: Partial<ISearchConditions>,
+
+  onSearchInvoked: (keyword: string, configurations: Partial<ISearchConfigurations>) => void,
+
+  deleteAllControl: React.ReactNode,
 }
 
-const SearchControl: FC <Props> = (props: Props) => {
+const SearchControl: FC <Props> = React.memo((props: Props) => {
+
+  const {
+    isSearchServiceReachable,
+    initialSearchConditions,
+    onSearchInvoked,
+    deleteAllControl,
+  } = props;
 
+  const [keyword, setKeyword] = useState(initialSearchConditions.keyword ?? '');
+  const [sort, setSort] = useState<SORT_AXIS>(initialSearchConditions.sort ?? SORT_AXIS.RELATION_SCORE);
+  const [order, setOrder] = useState<SORT_ORDER>(initialSearchConditions.order ?? SORT_ORDER.DESC);
+  const [includeUserPages, setIncludeUserPages] = useState(initialSearchConditions.includeUserPages ?? false);
+  const [includeTrashPages, setIncludeTrashPages] = useState(initialSearchConditions.includeTrashPages ?? false);
   const [isFileterOptionModalShown, setIsFileterOptionModalShown] = useState(false);
-  // Temporaly workaround for lint error
-  // later needs to be fixed: SearchControl to typescript componet
-  const SearchPageFormTypeAny : any = SearchPageForm;
-  const { t } = useTranslation('');
-  const { searchResultCount } = props;
 
-  const switchExcludeUserPagesHandler = () => {
-    if (props.onExcludeUserPagesSwitched != null) {
-      props.onExcludeUserPagesSwitched();
-    }
-  };
+  const { t } = useTranslation('');
 
-  const switchExcludeTrashPagesHandler = () => {
-    if (props.onExcludeTrashPagesSwitched != null) {
-      props.onExcludeTrashPagesSwitched();
+  const invokeSearch = useCallback(() => {
+    if (onSearchInvoked == null) {
+      return;
     }
-  };
 
-  const onChangeSortInvoked = (nextSort: SORT_AXIS, nextOrder:SORT_ORDER) => {
-    if (props.onChangeSortInvoked != null) {
-      props.onChangeSortInvoked(nextSort, nextOrder);
-    }
-  };
+    onSearchInvoked(keyword, {
+      sort, order, includeUserPages, includeTrashPages,
+    });
+  }, [keyword, sort, order, includeTrashPages, includeUserPages, onSearchInvoked]);
 
-  const openSearchOptionModalHandler = () => {
-    setIsFileterOptionModalShown(true);
-  };
+  const searchFormSubmittedHandler = useCallback((input: string) => {
+    setKeyword(input);
+  }, []);
 
-  const closeSearchOptionModalHandler = () => {
-    setIsFileterOptionModalShown(false);
-  };
+  const changeSortHandler = useCallback((nextSort: SORT_AXIS, nextOrder: SORT_ORDER) => {
+    setSort(nextSort);
+    setOrder(nextOrder);
+  }, []);
 
-  const onRetrySearchInvoked = () => {
-    if (props.onSearchInvoked != null) {
-      props.onSearchInvoked({ keyword: props.searchingKeyword });
-    }
-  };
-
-  const rednerSearchOptionModal = () => {
-    return (
-      <SearchOptionModal
-        isOpen={isFileterOptionModalShown || false}
-        onClickFilteringSearchResult={onRetrySearchInvoked}
-        onClose={closeSearchOptionModalHandler}
-        onExcludeUserPagesSwitched={switchExcludeUserPagesHandler}
-        onExcludeTrashPagesSwitched={switchExcludeTrashPagesHandler}
-        excludeUserPages={props.excludeUserPages}
-        excludeTrashPages={props.excludeTrashPages}
-      />
-    );
-  };
-
-  const renderSortControl = () => {
-    return (
-      <SortControl
-        sort={props.sort}
-        order={props.order}
-        onChangeSortInvoked={onChangeSortInvoked}
-      />
-    );
-  };
+  useEffect(() => {
+    invokeSearch();
+  }, [invokeSearch]);
 
   return (
     <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">
-          <SearchPageFormTypeAny
-            keyword={props.searchingKeyword}
-            appContainer={props.appContainer}
-            onSearchFormChanged={props.onSearchInvoked}
+          <SearchForm
+            isSearchServiceReachable={isSearchServiceReachable}
+            keyword={keyword}
+            disableIncrementalSearch
+            onSubmit={searchFormSubmittedHandler}
           />
         </div>
 
         {/* sort option: show when screen is larger than lg */}
         <div className="mr-4 d-lg-flex d-none">
-          {renderSortControl()}
+          <SortControl
+            sort={sort}
+            order={order}
+            onChange={changeSortHandler}
+          />
         </div>
       </div>
       {/* TODO: replace the following elements deleteAll button , relevance button and include specificPath button component */}
       <div className="search-control d-flex align-items-center py-md-2 py-3 px-md-4 px-3 border-bottom border-gray">
         <div className="d-flex pl-md-2">
-          {/* Todo: design will be fixed in #80324. Function will be implemented in #77525 */}
-          <DeleteSelectedPageGroup
-            isSelectAllCheckboxDisabled={searchResultCount === 0}
-            selectAllCheckboxType={props.selectAllCheckboxType}
-            onClickDeleteAllButton={props.onClickDeleteAllButton}
-            onClickSelectAllCheckbox={props.onClickSelectAllCheckbox}
-          />
+          {deleteAllControl}
         </div>
         {/* sort option: show when screen is smaller than lg */}
         <div className="mr-md-4 mr-2 d-flex d-lg-none ml-auto">
-          {renderSortControl()}
+          <SortControl
+            sort={sort}
+            order={order}
+            onChange={changeSortHandler}
+          />
         </div>
         {/* filter option */}
         <div className="d-lg-none">
           <button
             type="button"
             className="btn"
-            onClick={openSearchOptionModalHandler}
+            onClick={() => setIsFileterOptionModalShown(true)}
           >
             <i className="icon-equalizer"></i>
           </button>
@@ -138,7 +112,8 @@ const SearchControl: FC <Props> = (props: Props) => {
                   className="mr-2"
                   type="checkbox"
                   id="flexCheckDefault"
-                  onClick={switchExcludeUserPagesHandler}
+                  defaultChecked={includeUserPages}
+                  onChange={e => setIncludeUserPages(e.target.checked)}
                 />
                 {t('Include Subordinated Target Page', { target: '/user' })}
               </label>
@@ -151,7 +126,8 @@ const SearchControl: FC <Props> = (props: Props) => {
                   className="mr-2"
                   type="checkbox"
                   id="flexCheckChecked"
-                  onClick={switchExcludeTrashPagesHandler}
+                  defaultChecked={includeTrashPages}
+                  onChange={e => setIncludeTrashPages(e.target.checked)}
                 />
                 {t('Include Subordinated Target Page', { target: '/trash' })}
               </label>
@@ -159,10 +135,19 @@ const SearchControl: FC <Props> = (props: Props) => {
           </div>
         </div>
       </div>
-      {rednerSearchOptionModal()}
+
+      <SearchOptionModal
+        isOpen={isFileterOptionModalShown || false}
+        onClose={() => setIsFileterOptionModalShown(false)}
+        includeUserPages={includeUserPages}
+        includeTrashPages={includeTrashPages}
+        onIncludeUserPagesSwitched={setIncludeUserPages}
+        onIncludeTrashPagesSwitched={setIncludeTrashPages}
+      />
+
     </div>
   );
-};
+});
 
 
 export default SearchControl;

+ 22 - 23
packages/app/src/components/SearchPage/SearchOptionModal.tsx

@@ -2,18 +2,17 @@ import React, { FC } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
+  Modal, ModalHeader, ModalBody,
 } from 'reactstrap';
 
 
 type Props = {
   isOpen: boolean,
-  excludeUserPages: boolean,
-  excludeTrashPages: boolean,
+  includeUserPages: boolean,
+  includeTrashPages: boolean,
   onClose?: () => void,
-  onExcludeUserPagesSwitched?: () => void,
-  onExcludeTrashPagesSwitched?: () => void,
-  onClickFilteringSearchResult?: () => void,
+  onIncludeUserPagesSwitched?: (isChecked: boolean) => void,
+  onIncludeTrashPagesSwitched?: (isChecked: boolean) => void,
 }
 
 const SearchOptionModal: FC<Props> = (props: Props) => {
@@ -21,7 +20,10 @@ const SearchOptionModal: FC<Props> = (props: Props) => {
   const { t } = useTranslation('');
 
   const {
-    isOpen, onClose, excludeUserPages, excludeTrashPages,
+    isOpen, includeUserPages, includeTrashPages,
+    onClose,
+    onIncludeUserPagesSwitched,
+    onIncludeTrashPagesSwitched,
   } = props;
 
   const onCloseModal = () => {
@@ -30,10 +32,15 @@ const SearchOptionModal: FC<Props> = (props: Props) => {
     }
   };
 
-  const onClickFilteringSearchResult = () => {
-    if (props.onClickFilteringSearchResult != null) {
-      props.onClickFilteringSearchResult();
-      onCloseModal();
+  const includeUserPagesChangeHandler = (isChecked: boolean) => {
+    if (onIncludeUserPagesSwitched != null) {
+      onIncludeUserPagesSwitched(isChecked);
+    }
+  };
+
+  const includeTrashPagesChangeHandler = (isChecked: boolean) => {
+    if (onIncludeTrashPagesSwitched != null) {
+      onIncludeTrashPagesSwitched(isChecked);
     }
   };
 
@@ -49,8 +56,8 @@ const SearchOptionModal: FC<Props> = (props: Props) => {
               <input
                 className="mr-2"
                 type="checkbox"
-                onChange={props.onExcludeUserPagesSwitched}
-                checked={!excludeUserPages}
+                onChange={e => includeUserPagesChangeHandler(e.target.checked)}
+                checked={includeUserPages}
               />
               {t('Include Subordinated Target Page', { target: '/user' })}
             </label>
@@ -60,22 +67,14 @@ const SearchOptionModal: FC<Props> = (props: Props) => {
               <input
                 className="mr-2"
                 type="checkbox"
-                onChange={props.onExcludeTrashPagesSwitched}
-                checked={!excludeTrashPages}
+                onChange={e => includeTrashPagesChangeHandler(e.target.checked)}
+                checked={includeTrashPages}
               />
               {t('Include Subordinated Target Page', { target: '/trash' })}
             </label>
           </div>
         </div>
       </ModalBody>
-      <ModalFooter>
-        <button
-          type="button"
-          className="btn btn-secondary"
-          onClick={onClickFilteringSearchResult}
-        >{t('search_result.search_again')}
-        </button>
-      </ModalFooter>
     </Modal>
   );
 };

+ 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;

+ 0 - 79
packages/app/src/components/SearchPage/SearchPageLayout.tsx

@@ -1,79 +0,0 @@
-import React, { FC } from 'react';
-import { useTranslation } from 'react-i18next';
-
-type SearchResultMeta = {
-  took?: number,
-  total?: number,
-  results?: number
-}
-
-type Props = {
-  SearchControl: React.FunctionComponent,
-  SearchResultList: React.FunctionComponent,
-  SearchResultContent: React.FunctionComponent,
-  searchResultMeta: SearchResultMeta,
-  searchingKeyword: string,
-  pagingLimit: number,
-  activePage: number,
-  onPagingLimitChanged: (limit: number) => void
-}
-
-const SearchPageLayout: FC<Props> = (props: Props) => {
-  const { t } = useTranslation('');
-  const {
-    SearchResultList, SearchControl, SearchResultContent, searchResultMeta, searchingKeyword, pagingLimit, activePage,
-  } = props;
-
-  const renderShowingPageCountInfo = () => {
-    if (searchResultMeta.total == null || searchResultMeta.total === 0) return;
-    const leftNum = pagingLimit * (activePage - 1) + 1;
-    const rightNum = (leftNum - 1) + (searchResultMeta.results || 0);
-    return <span className="ml-3">{`${leftNum}-${rightNum}`} / {searchResultMeta.total || 0}</span>;
-  };
-
-  return (
-    <div className="content-main">
-      <div className="search-result d-flex" id="search-result">
-        <div className="mw-0 flex-grow-1 flex-basis-0 border boder-gray search-result-list" id="search-result-list">
-
-          <SearchControl></SearchControl>
-          <div className="search-result-list-scroll">
-            <div className="d-flex align-items-center justify-content-between my-3 ml-4">
-              <div className="search-result-meta text-nowrap">
-                <span className="font-weight-light">{t('search_result.result_meta')} </span>
-                <span className="h5">{`"${searchingKeyword}"`}</span>
-                {/* Todo: replace "1-10" to the appropriate value */}
-                {renderShowingPageCountInfo()}
-              </div>
-              <div className="input-group search-result-select-group ml-4 d-lg-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>
-                <select
-                  defaultValue={props.pagingLimit}
-                  className="custom-select"
-                  id="inputGroupSelect01"
-                  onChange={(e) => { props.onPagingLimitChanged(Number(e.target.value)) }}
-                >
-                  {[20, 50, 100, 200].map((limit) => {
-                    return <option key={limit} value={limit}>{limit}{t('search_result.page_number_unit')}</option>;
-                  })}
-                </select>
-              </div>
-            </div>
-
-            <div className="page-list px-md-4">
-              <SearchResultList></SearchResultList>
-            </div>
-          </div>
-        </div>
-        <div className="mw-0 flex-grow-1 flex-basis-0 d-none d-lg-block search-result-content">
-          <SearchResultContent></SearchResultContent>
-        </div>
-      </div>
-    </div>
-  );
-};
-
-
-export default SearchPageLayout;

+ 19 - 16
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -48,8 +48,8 @@ const MUTATION_OBSERVER_CONFIG = { childList: true, subtree: true };
 
 type Props ={
   appContainer: AppContainer,
-  searchingKeyword:string,
-  focusedSearchResultData : IPageWithMeta<IPageSearchMeta>,
+  pageWithMeta : IPageWithMeta<IPageSearchMeta>,
+  highlightKeywords?: string[],
   showPageControlDropdown?: boolean,
 }
 
@@ -72,7 +72,7 @@ const generateObserverCallback = (doScroll: ()=>void) => {
   };
 };
 
-const SearchResultContent: FC<Props> = (props: Props) => {
+export const SearchResultContent: FC<Props> = (props: Props) => {
   const scrollElementRef = useRef(null);
 
   // ***************************  Auto Scroll  ***************************
@@ -94,7 +94,8 @@ const SearchResultContent: FC<Props> = (props: Props) => {
 
   const {
     appContainer,
-    focusedSearchResultData,
+    pageWithMeta,
+    highlightKeywords,
     showPageControlDropdown,
   } = props;
 
@@ -102,7 +103,7 @@ const SearchResultContent: FC<Props> = (props: Props) => {
   const { open: openRenameModal } = usePageRenameModal();
   const { open: openDeleteModal } = usePageDeleteModal();
 
-  const page = focusedSearchResultData?.pageData;
+  const page = pageWithMeta?.pageData;
 
   const growiRenderer = appContainer.getRenderer('searchresult');
 
@@ -137,6 +138,7 @@ 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}
@@ -146,29 +148,30 @@ const SearchResultContent: FC<Props> = (props: Props) => {
         </div>
       </>
     );
-  }, [page, showPageControlDropdown, renameItemClickedHandler, deleteItemClickedHandler]);
+  }, [page, showPageControlDropdown, duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler]);
 
   // return if page is null
   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}
           pagePath={page.path}
           revisionId={page.revision}
-          highlightKeywords={props.searchingKeyword}
+          highlightKeywords={highlightKeywords}
         />
       </div>
     </div>
   );
 };
-
-
-export default SearchResultContent;

+ 50 - 40
packages/app/src/components/SearchPage/SearchResultList.tsx

@@ -1,38 +1,62 @@
-import React, { FC } from 'react';
-import { IPageInfoForEntity, IPageWithMeta, isIPageInfoForListing } from '~/interfaces/page';
+import React, {
+  forwardRef,
+  ForwardRefRenderFunction, useCallback, useImperativeHandle, useRef,
+} from 'react';
+import { ISelectable, ISelectableAll } from '~/client/interfaces/selectable-all';
+import { IPageWithMeta, isIPageInfoForListing } from '~/interfaces/page';
 import { IPageSearchMeta } from '~/interfaces/search';
+import { useIsGuestUser } from '~/stores/context';
 import { useSWRxPageInfoForList } from '~/stores/page';
 
 import { PageListItemL } from '../PageList/PageListItemL';
-import PaginationWrapper from '../PaginationWrapper';
 
 
 type Props = {
-  pages: IPageWithMeta<IPageInfoForEntity & IPageSearchMeta>[],
-  selectedPagesIdList: Set<string>
-  isEnableActions: boolean,
-  searchResultCount?: number,
-  activePage?: number,
-  pagingLimit?: number,
-  focusedSearchResultData?: IPageWithMeta<IPageSearchMeta>,
-  onPagingNumberChanged?: (activePage: number) => void,
-  onClickItem?: (pageId: string) => void,
-  onClickCheckbox?: (pageId: string) => void,
-  onClickInvoked?: (pageId: string) => void,
-  onClickDeleteButton?: (pageId: string) => void,
+  pages: IPageWithMeta<IPageSearchMeta>[],
+  selectedPageId?: string,
+  onPageSelected?: (page?: IPageWithMeta<IPageSearchMeta>) => void,
+  onCheckboxChanged?: (isChecked: boolean, pageId: string) => void,
 }
 
-const SearchResultList: FC<Props> = (props:Props) => {
+const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props> = (props:Props, ref) => {
   const {
-    pages, focusedSearchResultData, selectedPagesIdList, isEnableActions,
+    pages, selectedPageId,
+    onPageSelected,
   } = props;
 
   const pageIdsWithNoSnippet = pages
     .filter(page => (page.pageMeta?.elasticSearchResult?.snippet.length ?? 0) === 0)
     .map(page => page.pageData._id);
 
+  const { data: isGuestUser } = useIsGuestUser();
   const { data: idToPageInfo } = useSWRxPageInfoForList(pageIdsWithNoSnippet);
 
+  const itemsRef = useRef<(ISelectable|null)[]>([]);
+
+  // publish selectAll()
+  useImperativeHandle(ref, () => ({
+    selectAll: () => {
+      const items = itemsRef.current;
+      if (items != null) {
+        items.forEach(item => item != null && item.select());
+      }
+    },
+    deselectAll: () => {
+      const items = itemsRef.current;
+      if (items != null) {
+        items.forEach(item => item != null && item.deselect());
+      }
+    },
+  }));
+
+  const clickItemHandler = useCallback((pageId: string) => {
+    if (onPageSelected != null) {
+      const selectedPage = pages.find(page => page.pageData._id === pageId);
+      onPageSelected(selectedPage);
+    }
+  }, [onPageSelected, pages]);
+
+
   let injectedPage;
   // inject data to list
   if (idToPageInfo != null) {
@@ -54,39 +78,25 @@ const SearchResultList: FC<Props> = (props:Props) => {
     });
   }
 
-  const focusedPageId = (focusedSearchResultData != null && focusedSearchResultData.pageData != null) ? focusedSearchResultData.pageData._id : '';
   return (
-    <ul className="page-list-ul list-group list-group-flush">
-      { (injectedPage ?? pages).map((page) => {
-        const isChecked = selectedPagesIdList.has(page.pageData._id);
-
+    <ul data-testid="search-result-list" className="page-list-ul list-group list-group-flush">
+      { (injectedPage ?? pages).map((page, i) => {
         return (
           <PageListItemL
             key={page.pageData._id}
+            // eslint-disable-next-line no-return-assign
+            ref={c => itemsRef.current[i] = c}
             page={page}
-            isEnableActions={isEnableActions}
-            onClickItem={props.onClickItem}
-            onClickCheckbox={props.onClickCheckbox}
-            isChecked={isChecked}
-            isSelected={page.pageData._id === focusedPageId || false}
-            onClickDeleteButton={props.onClickDeleteButton}
+            isEnableActions={!isGuestUser}
+            isSelected={page.pageData._id === selectedPageId}
+            onClickItem={clickItemHandler}
+            onCheckboxChanged={props.onCheckboxChanged}
           />
         );
       })}
-      {props.searchResultCount != null && props.searchResultCount > 0 && (
-        <div className="my-4 mx-auto">
-          <PaginationWrapper
-            activePage={props.activePage || 1}
-            changePage={props.onPagingNumberChanged}
-            totalItemsCount={props.searchResultCount || 0}
-            pagingLimit={props.pagingLimit}
-          />
-        </div>
-      )}
-
     </ul>
   );
 
 };
 
-export default SearchResultList;
+export const SearchResultList = forwardRef(SearchResultListSubstance);

+ 10 - 8
packages/app/src/components/SearchPage/SortControl.tsx

@@ -7,30 +7,32 @@ const { DESC, ASC } = SORT_ORDER;
 type Props = {
   sort: SORT_AXIS,
   order: SORT_ORDER,
-  onChangeSortInvoked?: (nextSort: SORT_AXIS, nextOrder: SORT_ORDER) => void,
+  onChange?: (nextSort: SORT_AXIS, nextOrder: SORT_ORDER) => void,
 }
 
 const SortControl: FC <Props> = (props: Props) => {
 
   const { t } = useTranslation('');
 
+  const { sort, order, onChange } = props;
+
   const onClickChangeSort = (nextSortAxis: SORT_AXIS, nextSortOrder: SORT_ORDER) => {
-    if (props.onChangeSortInvoked != null) {
-      props.onChangeSortInvoked(nextSortAxis, nextSortOrder);
+    if (onChange != null) {
+      onChange(nextSortAxis, nextSortOrder);
     }
   };
 
-  const renderOrderIcon = (order: SORT_ORDER) => {
+  const renderOrderIcon = () => {
     const iconClassName = ASC === order ? 'fa fa-sort-amount-asc' : 'fa fa-sort-amount-desc';
     return <i className={iconClassName} aria-hidden="true" />;
   };
 
   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(props.order)}
+            {renderOrderIcon()}
           </div>
         </div>
         <div className="border rounded-right">
@@ -39,11 +41,11 @@ const SortControl: FC <Props> = (props: Props) => {
             className="btn dropdown-toggle search-sort-option-btn"
             data-toggle="dropdown"
           >
-            <span className="mr-4 text-secondary">{t(`search_result.sort_axis.${props.sort}`)}</span>
+            <span className="mr-4 text-secondary">{t(`search_result.sort_axis.${sort}`)}</span>
           </button>
           <div className="dropdown-menu dropdown-menu-right">
             {Object.values(SORT_AXIS).map((sortAxis) => {
-              const nextOrder = (props.sort !== sortAxis || props.order === ASC) ? DESC : ASC;
+              const nextOrder = (sort !== sortAxis || order === ASC) ? DESC : ASC;
               return (
                 <button
                   key={sortAxis}

+ 180 - 0
packages/app/src/components/SearchPage2/SearchPageBase.tsx

@@ -0,0 +1,180 @@
+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';
+import { IPageSearchMeta } from '~/interfaces/search';
+import { useIsGuestUser } from '~/stores/context';
+
+import { SearchResultContent } from '../SearchPage/SearchResultContent';
+import { SearchResultList } from '../SearchPage/SearchResultList';
+
+type Props = {
+  appContainer: AppContainer,
+
+  pages?: IPageWithMeta<IPageSearchMeta>[],
+
+  onSelectedPagesByCheckboxesChanged?: (selectedCount: number, totalCount: number) => void,
+
+  searchControl: React.ReactNode,
+  searchResultListHead: React.ReactNode,
+  searchPager: React.ReactNode,
+}
+
+const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll, Props> = (props:Props, ref) => {
+  const { t } = useTranslation();
+
+  const {
+    appContainer,
+    pages,
+    onSelectedPagesByCheckboxesChanged,
+    searchControl, searchResultListHead, searchPager,
+  } = props;
+
+  const searchResultListRef = useRef<ISelectableAll|null>(null);
+
+  const { data: isGuestUser } = useIsGuestUser();
+
+  // TODO get search keywords and split
+  // ref: RevisionRenderer
+  //   [...keywords.match(/"[^"]+"|[^\u{20}\u{3000}]+/ug)].forEach((keyword, i) => {
+  const [highlightKeywords, setHightlightKeywords] = useState<string[]>([]);
+  const [selectedPageIdsByCheckboxes] = useState<Set<string>>(new Set());
+  // const [allPageIds] = useState<Set<string>>(new Set());
+  const [selectedPageWithMeta, setSelectedPageWithMeta] = useState<IPageWithMeta<IPageSearchMeta> | undefined>();
+
+  // publish selectAll()
+  useImperativeHandle(ref, () => ({
+    selectAll: () => {
+      const instance = searchResultListRef.current;
+      if (instance != null) {
+        instance.selectAll();
+      }
+
+      if (pages != null) {
+        pages.forEach(page => selectedPageIdsByCheckboxes.add(page.pageData._id));
+      }
+    },
+    deselectAll: () => {
+      const instance = searchResultListRef.current;
+      if (instance != null) {
+        instance.deselectAll();
+      }
+
+      selectedPageIdsByCheckboxes.clear();
+    },
+  }));
+
+  const checkboxChangedHandler = (isChecked: boolean, pageId: string) => {
+    if (pages == null || pages.length === 0) {
+      return;
+    }
+
+    if (isChecked) {
+      selectedPageIdsByCheckboxes.add(pageId);
+    }
+    else {
+      selectedPageIdsByCheckboxes.delete(pageId);
+    }
+
+    if (onSelectedPagesByCheckboxesChanged != null) {
+      onSelectedPagesByCheckboxesChanged(selectedPageIdsByCheckboxes.size, pages.length);
+    }
+  };
+
+  // select first item on load
+  useEffect(() => {
+    if (selectedPageWithMeta == null && pages != null && pages.length > 0) {
+      setSelectedPageWithMeta(pages[0]);
+    }
+  }, [pages, selectedPageWithMeta]);
+
+  // reset selectedPageIdsByCheckboxes
+  useEffect(() => {
+    if (pages == null) {
+      return;
+    }
+
+    if (pages.length > 0) {
+      selectedPageIdsByCheckboxes.clear();
+    }
+
+    if (onSelectedPagesByCheckboxesChanged != null) {
+      onSelectedPagesByCheckboxesChanged(selectedPageIdsByCheckboxes.size, pages.length);
+    }
+  }, [onSelectedPagesByCheckboxesChanged, pages, selectedPageIdsByCheckboxes]);
+
+  return (
+    <div className="content-main">
+      <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">
+
+          {searchControl}
+
+          <div className="search-result-list-scroll">
+
+            {/* 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>
+            ) }
+
+            {/* Loaded */}
+            { pages != null && (
+              <>
+                <div className="my-3 px-md-4 px-3">
+                  {searchResultListHead}
+                </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>
+              </>
+            ) }
+
+          </div>
+
+        </div>
+
+        <div className="mw-0 flex-grow-1 flex-basis-0 d-none d-lg-block search-result-content">
+          { selectedPageWithMeta != null && (
+            <SearchResultContent
+              appContainer={appContainer}
+              pageWithMeta={selectedPageWithMeta}
+              highlightKeywords={highlightKeywords}
+              showPageControlDropdown={!isGuestUser}
+            />
+          )}
+        </div>
+
+      </div>
+    </div>
+  );
+};
+
+
+export const SearchPageBase = forwardRef(SearchPageBaseSubstance);

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

+ 15 - 7
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -13,7 +13,9 @@ import { pathUtils, pagePathUtils } from '@growi/core';
 import { toastWarning, toastError, toastSuccess } from '~/client/util/apiNotification';
 
 import { useSWRxPageChildren } from '~/stores/page-listing';
+import { useSWRxPageInfo } from '~/stores/page';
 import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
+import { useShareLinkId } from '~/stores/context';
 import { IPageForPageDeleteModal } from '~/stores/modal';
 
 import TriangleIcon from '~/components/Icons/TriangleIcon';
@@ -29,7 +31,7 @@ interface ItemProps {
   isOpen?: boolean
   onClickDuplicateMenuItem?(pageId: string, path: string): void
   onClickRenameMenuItem?(pageId: string, revisionId: string, path: string): void
-  onClickDeleteMenuItem?(pageToDelete: IPageForPageDeleteModal | null): void
+  onClickDeleteMenuItem?(pageToDelete: IPageForPageDeleteModal | null, isAbleToDeleteCompletely: boolean): void
 }
 
 // Utility to mark target
@@ -76,6 +78,8 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   const { page, children } = itemNode;
 
   const [pageTitle, setPageTitle] = useState(page.path);
+  const { data: shareLinkId } = useShareLinkId();
+  const { data: pageInfo } = useSWRxPageInfo(page._id ?? null, shareLinkId);
   const [currentChildren, setCurrentChildren] = useState(children);
   const [isOpen, setIsOpen] = useState(_isOpen);
   const [isNewPageInputShown, setNewPageInputShown] = useState(false);
@@ -104,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(),
@@ -237,7 +244,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     onClickRenameMenuItem(pageId, revisionId as string, path);
   }, [onClickRenameMenuItem, page]);
 
-  const onClickDeleteButton = useCallback(async(_pageId: string): Promise<void> => {
+  const deleteMenuItemClickHandler = useCallback(async(_pageId: string): Promise<void> => {
     if (onClickDeleteMenuItem == null) {
       return;
     }
@@ -253,9 +260,10 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       revisionId: revisionId as string,
       path,
     };
+    const isAbleToDeleteCompletely = pageInfo?.isAbleToDeleteCompletely ?? false;
 
-    onClickDeleteMenuItem(pageToDelete);
-  }, [page, onClickDeleteMenuItem]);
+    onClickDeleteMenuItem(pageToDelete, isAbleToDeleteCompletely);
+  }, [onClickDeleteMenuItem, page, pageInfo?.isAbleToDeleteCompletely]);
 
   const onPressEnterForCreateHandler = async(inputText: string) => {
     setNewPageInputShown(false);
@@ -382,7 +390,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
             showBookmarkMenuItem
             onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
             onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
-            onClickDeleteMenuItem={onClickDeleteButton}
+            onClickDeleteMenuItem={deleteMenuItemClickHandler}
             onClickRenameMenuItem={renameMenuItemClickHandler}
           >
             <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0">

+ 3 - 3
packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx

@@ -64,7 +64,7 @@ const renderByInitialNode = (
     targetPathOrId?: string,
     onClickDuplicateMenuItem?: (pageId: string, path: string) => void,
     onClickRenameMenuItem?: (pageId: string, revisionId: string, path: string) => void,
-    onClickDeleteMenuItem?: (pageToDelete: IPageForPageDeleteModal | null) => void,
+    onClickDeleteMenuItem?: (pageToDelete: IPageForPageDeleteModal | null, isAbleToDeleteCompletely: boolean) => void,
 ): JSX.Element => {
 
   return (
@@ -146,8 +146,8 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
     }
   };
 
-  const onClickDeleteMenuItem = (pageToDelete: IPageForPageDeleteModal) => {
-    openDeleteModal([pageToDelete], onDeletedHandler);
+  const onClickDeleteMenuItem = (pageToDelete: IPageForPageDeleteModal, isAbleToDeleteCompletely) => {
+    openDeleteModal([pageToDelete], onDeletedHandler, isAbleToDeleteCompletely);
   };
 
   if (error1 != null || error2 != null) {

+ 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>
   );

+ 9 - 13
packages/app/src/interfaces/search.ts

@@ -1,11 +1,5 @@
 import { IPageInfoAll, IPageWithMeta } from './page';
 
-export enum CheckboxType {
-  NONE_CHECKED = 'noneChecked',
-  INDETERMINATE = 'indeterminate',
-  ALL_CHECKED = 'allChecked',
-}
-
 export type IPageSearchMeta = {
   bookmarkCount?: number,
   elasticSearchResult?: {
@@ -19,18 +13,20 @@ export const isIPageSearchMeta = (meta: IPageInfoAll | (IPageInfoAll & IPageSear
   return meta != null && 'elasticSearchResult' in meta;
 };
 
-export type IFormattedSearchResult = {
-  data: IPageWithMeta<IPageSearchMeta>[]
-
-  totalCount: number
+export type ISearchResult<T > = ISearchResultMeta & {
+  data: T[],
+}
 
+export type ISearchResultMeta = {
   meta: {
-    total: number
     took?: number
-    count?: number
-  }
+    total: number
+    hitsCount: number
+  },
 }
 
+export type IFormattedSearchResult = ISearchResult<IPageWithMeta<IPageSearchMeta>>;
+
 export const SORT_AXIS = {
   RELATION_SCORE: 'relationScore',
   CREATED_AT: 'createdAt',

+ 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);

+ 2 - 12
packages/app/src/server/interfaces/search.ts

@@ -1,5 +1,6 @@
 /* eslint-disable camelcase */
 import { SearchDelegatorName } from '~/interfaces/named-query';
+import { ISearchResult } from '~/interfaces/search';
 
 
 export type QueryTerms = {
@@ -25,18 +26,7 @@ export interface SearchResolver{
 
 export interface SearchDelegator<T = unknown> {
   name?: SearchDelegatorName
-  search(data: SearchableData | null, user, userGroups, option): Promise<Result<T> & MetaData>
-}
-
-export type Result<T> = {
-  data: T[]
-}
-
-export type MetaData = {
-  meta: {
-    [key:string]: any,
-    total: number,
-  }
+  search(data: SearchableData | null, user, userGroups, option): Promise<ISearchResult<T>>
 }
 
 export type SearchableData = {

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

+ 40 - 17
packages/app/src/server/service/search-delegator/elasticsearch.ts

@@ -9,12 +9,14 @@ import streamToPromise from 'stream-to-promise';
 
 import { createBatchStream } from '../../util/batch-stream';
 import loggerFactory from '~/utils/logger';
-import { PageDocument, PageModel } from '../../models/page';
+import { PageModel } from '../../models/page';
 import {
-  MetaData, SearchDelegator, Result, SearchableData, QueryTerms,
+  SearchDelegator, SearchableData, QueryTerms,
 } from '../../interfaces/search';
 import { SearchDelegatorName } from '~/interfaces/named-query';
-import { SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
+import {
+  IFormattedSearchResult, ISearchResult, SORT_AXIS, SORT_ORDER,
+} from '~/interfaces/search';
 import ElasticsearchClient from './elasticsearch-client';
 
 const logger = loggerFactory('growi:service:search-delegator:elasticsearch');
@@ -147,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;
   }
@@ -282,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 });
@@ -290,6 +299,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
       throw error;
     }
     finally {
+      logger.warn('Normalize indices anyway.');
       await this.normalizeIndices();
     }
 
@@ -323,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,
+    });
   }
 
   /**
@@ -611,30 +631,33 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
    *   data: [ pages ...],
    * }
    */
-  async searchKeyword(query) {
+  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 {
       meta: {
         took: result.took,
         total: totalValue,
-        results: result.hits.hits.length,
+        hitsCount: result.hits.hits.length,
       },
       data: result.hits.hits.map((elm) => {
         return {
@@ -663,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,
       },
     };
 
@@ -685,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) {
@@ -940,7 +963,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
     };
   }
 
-  async search(data: SearchableData, user, userGroups, option): Promise<Result<Data> & MetaData> {
+  async search(data: SearchableData, user, userGroups, option): Promise<ISearchResult<unknown>> {
     const { queryString, terms } = data;
 
     const from = option.offset || null;

+ 4 - 2
packages/app/src/server/service/search-delegator/private-legacy-pages.ts

@@ -4,9 +4,10 @@ import { PageModel, PageDocument } from '~/server/models/page';
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import { IPage } from '~/interfaces/page';
 import {
-  MetaData, Result, SearchableData, SearchDelegator,
+  SearchableData, SearchDelegator,
 } from '../../interfaces/search';
 import { serializeUserSecurely } from '../../models/serializers/user-serializer';
+import { ISearchResult } from '~/interfaces/search';
 
 
 class PrivateLegacyPagesDelegator implements SearchDelegator<IPage> {
@@ -17,7 +18,7 @@ class PrivateLegacyPagesDelegator implements SearchDelegator<IPage> {
     this.name = SearchDelegatorName.PRIVATE_LEGACY_PAGES;
   }
 
-  async search(_data: SearchableData | null, user, userGroups, option): Promise<Result<IPage> & MetaData> {
+  async search(_data: SearchableData | null, user, userGroups, option): Promise<ISearchResult<IPage>> {
     const { offset, limit } = option;
 
     if (offset == null || limit == null) {
@@ -51,6 +52,7 @@ class PrivateLegacyPagesDelegator implements SearchDelegator<IPage> {
       data: pages,
       meta: {
         total: pages.length,
+        hitsCount: pages.length,
       },
     };
   }

+ 4 - 6
packages/app/src/server/service/search.ts

@@ -1,12 +1,12 @@
 import xss from 'xss';
 
 import { SearchDelegatorName } from '~/interfaces/named-query';
-import { IFormattedSearchResult } from '~/interfaces/search';
+import { IFormattedSearchResult, ISearchResult, ISearchResultMeta } from '~/interfaces/search';
 import loggerFactory from '~/utils/logger';
 
 import NamedQuery from '../models/named-query';
 import {
-  SearchDelegator, SearchQueryParser, SearchResolver, ParsedQuery, Result, MetaData, SearchableData, QueryTerms,
+  SearchDelegator, SearchQueryParser, SearchResolver, ParsedQuery, SearchableData, QueryTerms,
 } from '../interfaces/search';
 import ElasticsearchDelegator from './search-delegator/elasticsearch';
 import PrivateLegacyPagesDelegator from './search-delegator/private-legacy-pages';
@@ -236,7 +236,7 @@ class SearchService implements SearchQueryParser, SearchResolver {
     return [this.nqDelegators[SearchDelegatorName.DEFAULT], data];
   }
 
-  async searchKeyword(keyword: string, user, userGroups, searchOpts): Promise<[Result<any> & MetaData, string]> {
+  async searchKeyword(keyword: string, user, userGroups, searchOpts): Promise<[ISearchResult<unknown>, string]> {
     let parsedQuery;
     // parse
     try {
@@ -348,7 +348,7 @@ class SearchService implements SearchQueryParser, SearchResolver {
   /**
    * formatting result
    */
-  async formatSearchResult(searchResult: Result<any> & MetaData, delegatorName): Promise<IFormattedSearchResult> {
+  async formatSearchResult(searchResult: ISearchResult<any>, delegatorName): Promise<IFormattedSearchResult> {
     if (!this.checkIsFormattable(searchResult, delegatorName)) {
       const data = searchResult.data.map((page) => {
         return {
@@ -359,7 +359,6 @@ class SearchService implements SearchQueryParser, SearchResolver {
 
       return {
         data,
-        totalCount: data.length,
         meta: searchResult.meta,
       };
     }
@@ -377,7 +376,6 @@ class SearchService implements SearchQueryParser, SearchResolver {
 
     // set meta data
     result.meta = searchResult.meta;
-    result.totalCount = findPageResult.totalCount;
 
     // set search result page data
     result.data = searchResult.data.map((data) => {

+ 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

+ 25 - 3
packages/app/src/stores/modal.tsx

@@ -34,6 +34,8 @@ export type IPageForPageDeleteModal = {
   pageId: string,
   revisionId?: string,
   path: string
+  isAbleToDeleteCompletely?: boolean,
+  isDeleteCompletelyModal?: boolean,
 }
 
 export type OnDeletedFunction = (pathOrPaths: string | string[], isRecursively: Nullable<true>, isCompletely: Nullable<true>) => void;
@@ -42,20 +44,40 @@ type DeleteModalStatus = {
   isOpened: boolean,
   pages?: IPageForPageDeleteModal[],
   onDeleted?: OnDeletedFunction,
+  isAbleToDeleteCompletely?: boolean,
+  isDeleteCompletelyModal?: boolean,
 }
 
 type DeleteModalStatusUtils = {
-  open(pages?: IPageForPageDeleteModal[], onDeleted?: OnDeletedFunction): Promise<DeleteModalStatus | undefined>,
+  open(
+    pages?: IPageForPageDeleteModal[],
+    onDeleted?: OnDeletedFunction,
+    isAbleToDeleteCompletely?: boolean,
+    isDeleteCompletelyModal?: boolean,
+  ): Promise<DeleteModalStatus | undefined>,
   close(): Promise<DeleteModalStatus | undefined>,
 }
 
 export const usePageDeleteModal = (status?: DeleteModalStatus): SWRResponse<DeleteModalStatus, Error> & DeleteModalStatusUtils => {
-  const initialData: DeleteModalStatus = { isOpened: false, pages: [], onDeleted: () => {} };
+  const initialData: DeleteModalStatus = {
+    isOpened: false,
+    pages: [],
+    onDeleted: () => {},
+    isAbleToDeleteCompletely: false,
+    isDeleteCompletelyModal: false,
+  };
   const swrResponse = useStaticSWR<DeleteModalStatus, Error>('deleteModalStatus', status, { fallbackData: initialData });
 
   return {
     ...swrResponse,
-    open: (pages?: IPageForPageDeleteModal[], onDeleted?: OnDeletedFunction) => swrResponse.mutate({ isOpened: true, pages, onDeleted }),
+    open: (
+        pages?: IPageForPageDeleteModal[],
+        onDeleted?: OnDeletedFunction,
+        isAbleToDeleteCompletely?: boolean,
+        isDeleteCompletelyModal?: boolean,
+    ) => swrResponse.mutate({
+      isOpened: true, pages, onDeleted, isAbleToDeleteCompletely, isDeleteCompletelyModal,
+    }),
     close: () => swrResponse.mutate({ isOpened: false }),
   };
 };

+ 92 - 0
packages/app/src/stores/search.tsx

@@ -0,0 +1,92 @@
+import { SWRResponse } from 'swr';
+import useSWRImmutable from 'swr/immutable';
+
+import { apiGet } from '~/client/util/apiv1-client';
+
+import { IFormattedSearchResult, SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
+
+
+export type ISearchConfigurations = {
+  limit: number,
+  offset?: number,
+  sort?: SORT_AXIS,
+  order?: SORT_ORDER,
+  includeTrashPages?: boolean,
+  includeUserPages?: boolean,
+}
+
+type ISearchConfigurationsFixed = {
+  limit: number,
+  offset: number,
+  sort: SORT_AXIS,
+  order: SORT_ORDER,
+  includeTrashPages: boolean,
+  includeUserPages: boolean,
+}
+
+export type ISearchConditions = ISearchConfigurationsFixed & {
+  keyword: string,
+  rawQuery: string,
+}
+
+const createSearchQuery = (keyword: string, includeTrashPages: boolean, includeUserPages: boolean): string => {
+  let query = keyword;
+
+  // pages included in specific path are not retrived when prefix is added
+  if (!includeTrashPages) {
+    query = `${query} -prefix:/trash`;
+  }
+  if (!includeUserPages) {
+    query = `${query} -prefix:/user`;
+  }
+
+  return query;
+};
+
+export const useSWRxFullTextSearch = (
+    keyword: string, configurations: ISearchConfigurations,
+): SWRResponse<IFormattedSearchResult, Error> & { conditions: ISearchConditions } => {
+
+  const {
+    limit, offset, sort, order, includeTrashPages, includeUserPages,
+  } = configurations;
+
+  const fixedConfigurations: ISearchConfigurationsFixed = {
+    limit,
+    offset: offset ?? 0,
+    sort: sort ?? SORT_AXIS.RELATION_SCORE,
+    order: order ?? SORT_ORDER.DESC,
+    includeTrashPages: includeTrashPages ?? false,
+    includeUserPages: includeUserPages ?? false,
+  };
+  const rawQuery = createSearchQuery(keyword, fixedConfigurations.includeTrashPages, fixedConfigurations.includeUserPages);
+
+  const swrResult = useSWRImmutable(
+    ['/search', keyword, fixedConfigurations],
+    (endpoint, keyword, fixedConfigurations) => {
+      const {
+        limit, offset, sort, order,
+      } = fixedConfigurations;
+
+      return apiGet(
+        endpoint, {
+          q: encodeURIComponent(rawQuery),
+          limit,
+          offset,
+          sort,
+          order,
+        },
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      ).then(result => result as IFormattedSearchResult);
+    },
+  );
+
+  return {
+    ...swrResult,
+    conditions: {
+      keyword,
+      rawQuery,
+      ...fixedConfigurations,
+    },
+  };
+};

+ 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)),
   );
 };
 

+ 12 - 27
packages/app/src/styles/_search.scss

@@ -162,7 +162,8 @@
       }
     }
 
-    .search-result-meta {
+    .search-result-keyword {
+      font-size: 17.5px;
       font-weight: bold;
     }
     .search-result-select-group {
@@ -170,14 +171,14 @@
         max-width: 8rem;
       }
     }
-    .search-result-list-delete-checkbox {
-      margin: 0 10px 0 0;
-      vertical-align: middle;
-    }
-    // not show top label in search result list
-    .page-list-meta {
-      .top-label {
-        display: none;
+
+    // list group
+    .page-list {
+      // not show top label in search result list
+      .page-list-meta {
+        .top-label {
+          display: none;
+        }
       }
     }
   }
@@ -192,7 +193,7 @@
       }
     }
 
-    .search-result-page {
+    .search-result-content {
       height: calc(100vh - ($grw-navbar-height + $grw-navbar-border-width));
 
       > h2 {
@@ -205,34 +206,18 @@
         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;
         }
       }
     }
   }
 }
 
-// 2021/9/22 TODO: Remove after moving to SearchResult
-.search-page-input {
-  position: sticky;
-  top: 15px;
-  // placed at front-most
-  z-index: 15;
-
-  margin-bottom: 15px;
-
-  .input-group-btn .btn {
-    height: 34px;
-    padding: 0px 10px;
-  }
-}
-
 // support for your search
 .grw-search-table {
   caption {

+ 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);
-  }
 }
 
 /*

+ 4 - 0
packages/app/src/styles/atoms/_custom_control.scss

@@ -1,3 +1,7 @@
+.custom-checkbox .custom-control-label::before {
+  border-radius: $border-radius !important;
+}
+
 label.custom-control-label {
   font-weight: normal;
 }

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

+ 0 - 0
packages/app/test/cypress/integration/2-advanced-examples/misc.spec.ts → packages/app/test/cypress/integration/0-advanced-examples/misc.spec.ts


+ 0 - 0
packages/app/test/cypress/integration/2-advanced-examples/viewport.spec.ts → packages/app/test/cypress/integration/0-advanced-examples/viewport.spec.ts


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

@@ -0,0 +1,116 @@
+const ssPrefix = 'access-to-admin-page-';
+
+const adminMenues = [
+  'app', // App
+  'security', // Security
+  'security', // Security
+  'security', // Security
+  'security', // Security
+  'security', // Security
+  'security', // Security
+  'security', // Security
+  'security', // Security
+  'security', // Security
+  'security', // Security
+];
+
+context('Access to Admin page', () => {
+
+  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('/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' });
+  });
+
+});

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

@@ -0,0 +1,33 @@
+const ssPrefix = 'access-to-page-';
+
+context('Access to page', () => {
+
+  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('/me is successfully loaded', () => {
+    cy.visit('/me', {  });
+    cy.screenshot(`${ssPrefix}-me`, { capture: 'viewport' });
+  });
+
+  it('Draft page is successfully shown', () => {
+    cy.visit('/me/drafts');
+    cy.screenshot(`${ssPrefix}-draft-page`, { capture: 'viewport' });
+  });
+
+});

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

@@ -1,6 +1,6 @@
-const ssPrefix = 'access-to-page-';
 
 context('Access to page', () => {
+  const ssPrefix = 'access-to-page-';
 
   let connectSid: string | undefined;
 
@@ -30,4 +30,19 @@ context('Access to page', () => {
     cy.screenshot(`${ssPrefix}-sandbox-headers`, { capture: 'viewport' });
   });
 
+  it('/Sandbox/Math is successfully loaded', () => {
+    cy.visit('/Sandbox/Math');
+    cy.screenshot(`${ssPrefix}-sandbox-math`, { capture: 'viewport' });
+  });
+
+  it('/Sandbox with edit is successfully loaded', () => {
+    cy.visit('/Sandbox#edit');
+    cy.screenshot(`${ssPrefix}-sandbox-edit-page`, { capture: 'viewport' });
+  })
+
+  it('/user/admin is successfully loaded', () => {
+    cy.visit('/user/admin', {  });
+    cy.screenshot(`${ssPrefix}-user-admin`, { 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'});
+  });
+
+});

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

@@ -0,0 +1,53 @@
+context('Access to search result page directly', () => {
+  const ssPrefix = 'access-to-result-page-directly-';
+
+  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('/_search with "q" param is successfully loaded', () => {
+    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: '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' });
+    cy.getByTestid('cb-select').first().click({force: true});
+    cy.screenshot(`${ssPrefix}-the-first-checkbox-off`, { capture: 'viewport' });
+
+    // click select all checkbox
+    cy.getByTestid('cb-select-all').click({force: true});
+    cy.screenshot(`${ssPrefix}-the-select-all-checkbox-1`, { capture: 'viewport' });
+    cy.getByTestid('cb-select').first().click({force: true});
+    cy.screenshot(`${ssPrefix}-the-select-all-checkbox-2`, { capture: 'viewport' });
+    cy.getByTestid('cb-select').first().click({force: true});
+    cy.screenshot(`${ssPrefix}-the-select-all-checkbox-3`, { capture: 'viewport' });
+    cy.getByTestid('cb-select-all').click({force: true});
+    cy.screenshot(`${ssPrefix}-the-select-all-checkbox-4`, { capture: 'viewport' });
+  });
+
+});

+ 10 - 6
packages/app/test/integration/service/page-grant.test.js

@@ -109,13 +109,17 @@ describe('PageGrantService', () => {
     ]);
 
     // Root page (Depth: 0)
-    await Page.insertMany([
-      {
-        path: '/',
-        grant: Page.GRANT_PUBLIC,
-      },
-    ]);
     rootPage = await Page.findOne({ path: '/' });
+    if (rootPage == null) {
+      const pages = await Page.insertMany([
+        {
+          path: '/',
+          grant: Page.GRANT_PUBLIC,
+        },
+      ]);
+      rootPage = pages[0];
+    }
+
 
     // Empty pages (Depth: 1)
     await Page.insertMany([

+ 6 - 1
packages/app/test/integration/service/v5-migration.test.js

@@ -26,7 +26,7 @@ describe('V5 page migration', () => {
       jest.restoreAllMocks();
 
       // initialize pages for test
-      const pages = await Page.insertMany([
+      let pages = await Page.insertMany([
         {
           path: '/private1',
           grant: Page.GRANT_OWNER,
@@ -57,6 +57,11 @@ describe('V5 page migration', () => {
         },
       ]);
 
+      if (!await Page.exists({ path: '/' })) {
+        const additionalPages = await Page.insertMany([{ path: '/', grant: Page.GRANT_PUBLIC }]);
+        pages = [...additionalPages, ...pages];
+      }
+
       const pageIds = pages.map(page => page._id);
       // migrate
       await crowi.pageService.normalizeParentRecursivelyByPageIds(pageIds, testUser1);

Некоторые файлы не были показаны из-за большого количества измененных файлов