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

Merge branch 'master' into imprv/110234-show-spinner-while-loading

soumaeda 2 лет назад
Родитель
Сommit
ac24a09969
58 измененных файлов с 751 добавлено и 435 удалено
  1. 2 2
      .devcontainer/Dockerfile
  2. 1 1
      .github/workflows/reusable-app-prod.yml
  3. 44 1
      CHANGELOG.md
  4. 1 1
      apps/app/cypress.config.ts
  5. 1 0
      apps/app/docker/Dockerfile
  6. 0 1
      apps/app/docker/Dockerfile.dockerignore
  7. 4 4
      apps/app/package.json
  8. 0 166
      apps/app/src/components/Admin/Common/AdminNavigation.jsx
  9. 167 0
      apps/app/src/components/Admin/Common/AdminNavigation.tsx
  10. 3 2
      apps/app/src/components/Admin/Notification/ManageGlobalNotification.tsx
  11. 3 2
      apps/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  12. 2 2
      apps/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  13. 6 2
      apps/app/src/components/Admin/UserGroupDetail/UserGroupPageList.tsx
  14. 3 2
      apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx
  15. 3 3
      apps/app/src/components/Bookmarks/BookmarkFolderMenu.tsx
  16. 10 6
      apps/app/src/components/Bookmarks/BookmarkFolderTree.tsx
  17. 42 16
      apps/app/src/components/Bookmarks/BookmarkItem.tsx
  18. 1 6
      apps/app/src/components/DescendantsPageList.tsx
  19. 1 1
      apps/app/src/components/Layout/AdminLayout.tsx
  20. 2 0
      apps/app/src/components/Layout/BasicLayout.tsx
  21. 19 17
      apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  22. 6 3
      apps/app/src/components/Navbar/SubNavButtons.tsx
  23. 4 4
      apps/app/src/components/PageAlert/TrashPageAlert.tsx
  24. 15 2
      apps/app/src/components/PageEditor.tsx
  25. 21 21
      apps/app/src/components/PageList/PageListItemL.tsx
  26. 6 3
      apps/app/src/components/PageList/PageListItemS.tsx
  27. 20 7
      apps/app/src/components/ReactMarkdownComponents/NextLink.tsx
  28. 0 1
      apps/app/src/components/SearchPage/SearchResultList.tsx
  29. 7 0
      apps/app/src/components/TemplateModal/TemplateModal.module.scss
  30. 62 32
      apps/app/src/components/TemplateModal/TemplateModal.tsx
  31. 19 5
      apps/app/src/features/templates/server/routes/apiv3/index.ts
  32. 20 38
      apps/app/src/pages/[[...path]].page.tsx
  33. 0 8
      apps/app/src/pages/_search.page.tsx
  34. 0 2
      apps/app/src/pages/trash.page.tsx
  35. 19 5
      apps/app/src/server/service/search-delegator/elasticsearch-client.ts
  36. 6 6
      apps/app/src/server/service/search-delegator/elasticsearch.ts
  37. 1 1
      apps/app/src/services/renderer/remark-plugins/attachment.ts
  38. 19 17
      apps/app/test/cypress/e2e/20-basic-features/20-basic-features--access-to-page.cy.ts
  39. 1 0
      apps/app/test/cypress/e2e/20-basic-features/20-basic-features--sticky-features.cy.ts
  40. 1 0
      apps/app/test/cypress/e2e/22-sharelink/22-sharelink--access-to-sharelink.cy.ts
  41. 90 0
      apps/app/test/cypress/e2e/23-editor/23-editor--template-modal.cy.ts
  42. 31 8
      apps/app/test/cypress/e2e/23-editor/23-editor--with-navigation.cy.ts
  43. 2 2
      apps/slackbot-proxy/package.json
  44. 2 1
      package.json
  45. 2 2
      packages/core/package.json
  46. 1 1
      packages/core/src/utils/page-path-utils/index.ts
  47. 2 2
      packages/hackmd/package.json
  48. 1 1
      packages/pluginkit/package.json
  49. 2 2
      packages/presentation/package.json
  50. 1 1
      packages/preset-templates/package.json
  51. 1 1
      packages/preset-themes/package.json
  52. 2 2
      packages/remark-attachment-refs/package.json
  53. 2 2
      packages/remark-drawio/package.json
  54. 2 2
      packages/remark-growi-directive/package.json
  55. 2 2
      packages/remark-lsx/package.json
  56. 2 2
      packages/slack/package.json
  57. 2 2
      packages/ui/package.json
  58. 62 12
      yarn.lock

+ 2 - 2
.devcontainer/Dockerfile

@@ -39,9 +39,9 @@ RUN wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo apt-ke
 RUN apt-get update \
     && apt-get -y install --no-install-recommends git-lfs \
 
-    # Uncomment below lines to install Chrome and libs for Cypress
+    # Uncomment below lines to install Chromium
     # --- works only on AMD64 ---
-    # && apt-get -y install --no-install-recommends google-chrome-stable \
+    # && apt-get -y install --no-install-recommends chromium \
     #    libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 libxtst6 xauth xvfb fonts-noto-cjk \
 
     # Clean up

+ 1 - 1
.github/workflows/reusable-app-prod.yml

@@ -312,7 +312,7 @@ jobs:
     - name: Cypress Run
       uses: cypress-io/github-action@v5
       with:
-        browser: chrome
+        browser: chromium
         working-directory: ./apps/app
         spec: '${{ steps.determine-spec-exp.outputs.value }}'
         install: false

+ 44 - 1
CHANGELOG.md

@@ -1,9 +1,52 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v6.1.4...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v6.1.6...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v6.1.6](https://github.com/weseek/growi/compare/v6.1.5...v6.1.6) - 2023-07-12
+
+### 🐛 Bug Fixes
+
+- fix: Revert current page mutation and add workaround for saving page (#7877) @yuki-takei
+- fix: The official docker image missed preset-templates (#7865) @yuki-takei
+- fix: SSL connection error to Elasticsearch8 using self certificate (#7818) @miya
+
+## [v6.1.5](https://github.com/weseek/growi/compare/v6.1.4...v6.1.5) - 2023-07-10
+
+### 💎 Features
+
+- feat: Rich Attachment (#7534) @jam411
+- feat: Plugin kit (#7830) @yuki-takei
+- feat: Deciding whether to use SSR based on the volume of latestRevisionBodyLength (#7772) @miya
+
+### 🚀 Improvement
+
+- imprv: Load templates from the server 2 (#7850) @yuki-takei
+- imprv: Improve release parent group button (#7838) @WNomunomu
+- imprv: Load templates from the server (#7842) @yuki-takei
+- imprv: Able to send new passsword by email (#7758) @soumaeda
+- imprv: Convert jsx into tsx (#7832) @WNomunomu
+- imprv: After reset password footer modal design (#7790) @soumaeda
+- imprv: Update email alert (#7771) @WNomunomu
+- imprv: Can use normal browser transition in searching page (#7826) @yuki-takei
+- imprv: Show tooltip when copying password (#7800) @soumaeda
+
+### 🐛 Bug Fixes
+
+- fix(lsx): Except option (#7855) @yuki-takei
+- fix: Page body is not displayed when skipSSR (#7849) @miya
+- fix: When uploading an attachment file to a new page and pressing the update button, an error occurs (#7844) @miya
+- fix: Editing user group settings (#7827) @WNomunomu
+- fix: Handsontable not display full screen (#7784) @mudana-grune
+- fix: Brand logo fill color transition (#7828) @yuki-takei
+- fix: Email body of global notification is not displayed (#7824) @jam411
+- fix(lsx): Prefix is not uniquely determined by usage (#7815) @yuki-takei
+
+### 🧰 Maintenance
+
+- support: Dependencies specification for local packages (#7809) @yuki-takei
+
 ## [v6.1.4](https://github.com/weseek/growi/compare/v6.1.3...v6.1.4) - 2023-06-12
 
 ### 💎 Features

+ 1 - 1
apps/app/cypress.config.ts

@@ -9,7 +9,7 @@ export default defineConfig({
       // change screen size
       // see: https://docs.cypress.io/api/plugins/browser-launch-api#Set-screen-size-when-running-headless
       on('before:browser:launch', (browser, launchOptions) => {
-        if (browser.name === 'chrome' && browser.isHeadless) {
+        if (browser.name === 'chromium' && browser.isHeadless) {
           launchOptions.args.push('--window-size=1400,1024');
           launchOptions.args.push('--force-device-scale-factor=1');
         }

+ 1 - 0
apps/app/docker/Dockerfile

@@ -83,6 +83,7 @@ RUN tar -xf node_modules.tar
 RUN rm node_modules.tar
 
 # build
+RUN turbo run clean
 RUN turbo run build
 
 # make artifacts

+ 0 - 1
apps/app/docker/Dockerfile.dockerignore

@@ -1,5 +1,4 @@
 **/node_modules
-**/dist
 **/coverage
 **/Dockerfile
 **/*.dockerignore

+ 4 - 4
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "6.1.5-RC.0",
+  "version": "6.1.7-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -8,8 +8,8 @@
     "start": "yarn next start",
     "build:client": "yarn next build",
     "build:server": "yarn cross-env NODE_ENV=production tsc -p tsconfig.build.server.json && tsc-alias -p tsconfig.build.server-tsc-alias.json",
-    "postbuild:server": "npx -y shx echo \"Listing files under transpiled\" && npx -y shx ls transpiled && npx -y shx rm -rf dist && npx -y shx mv transpiled/src dist && npx -y shx rm -rf transpiled",
-    "clean": "npx -y shx rm -rf dist transpiled",
+    "postbuild:server": "shx echo \"Listing files under transpiled\" && shx ls transpiled && shx rm -rf dist && shx mv transpiled/src dist && shx rm -rf transpiled",
+    "clean": "shx rm -rf dist transpiled",
     "server": "yarn cross-env NODE_ENV=production node -r dotenv-flow/config dist/server/app.js",
     "server:ci": "yarn server --ci",
     "preserver": "yarn cross-env NODE_ENV=production yarn migrate",
@@ -24,7 +24,7 @@
     "dev:migrate:status": "yarn dev:migrate-mongo status -f config/migrate-mongo-config.js",
     "dev:migrate:up": "yarn dev:migrate-mongo up -f config/migrate-mongo-config.js",
     "dev:migrate:down": "yarn dev:migrate-mongo down -f config/migrate-mongo-config.js",
-    "cy:run": "cypress run --browser chrome",
+    "cy:run": "cypress run --browser chromium",
     "//// for CI": "",
     "dev:ci": "yarn cross-env NODE_ENV=development yarn ts-node src/server/app.ts --ci",
     "lint:typecheck": "npx -y tsc",

+ 0 - 166
apps/app/src/components/Admin/Common/AdminNavigation.jsx

@@ -1,166 +0,0 @@
-import React from 'react';
-
-import { pathUtils } from '@growi/core';
-import { useTranslation } from 'next-i18next';
-import Link from 'next/link';
-import PropTypes from 'prop-types';
-import urljoin from 'url-join';
-
-import { useGrowiCloudUri, useGrowiAppIdForGrowiCloud } from '../../../stores/context';
-// import AppContainer from '~/client/services/AppContainer';
-
-// import { withUnstatedContainers } from '../../UnstatedUtils';
-
-const AdminNavigation = (props) => {
-  const { t } = useTranslation(['admin', 'commons']);
-  // const { appContainer } = props;
-  const pathname = window.location.pathname;
-
-  const { data: growiCloudUri } = useGrowiCloudUri();
-  const { data: growiAppIdForGrowiCloud } = useGrowiAppIdForGrowiCloud();
-
-  // eslint-disable-next-line react/prop-types
-  const MenuLabel = ({ menu }) => {
-    switch (menu) {
-      /* eslint-disable no-multi-spaces, max-len */
-      case 'app':                      return <><i className="mr-1 icon-fw icon-settings"></i>{        t('headers.app_settings', { ns: 'commons' }) }</>;
-      case 'security':                 return <><i className="mr-1 icon-fw icon-shield"></i>{          t('security_settings.security_settings') }</>;
-      case 'markdown':                 return <><i className="mr-1 icon-fw icon-note"></i>{            t('markdown_settings.markdown_settings') }</>;
-      case 'customize':                return <><i className="mr-1 icon-fw icon-wrench"></i>{          t('customize_settings.customize_settings') }</>;
-      case 'importer':                 return <><i className="mr-1 icon-fw icon-cloud-upload"></i>{    t('importer_management.import_data') }</>;
-      case 'export':                   return <><i className="mr-1 icon-fw icon-cloud-download"></i>{  t('export_management.export_archive_data') }</>;
-      case 'data-transfer':            return <><i className="mr-1 icon-fw icon-plane"></i>{           t('g2g_data_transfer.data_transfer', { ns: 'commons' })}</>;
-      case 'notification':             return <><i className="mr-1 icon-fw icon-bell"></i>{            t('external_notification.external_notification')}</>;
-      case 'slack-integration':        return <><i className="mr-1 icon-fw icon-shuffle"></i>{         t('slack_integration.slack_integration') }</>;
-      case 'slack-integration-legacy': return <><i className="mr-1 icon-fw icon-shuffle"></i>{         t('slack_integration_legacy.slack_integration_legacy')}</>;
-      case 'users':                    return <><i className="mr-1 icon-fw icon-user"></i>{            t('user_management.user_management') }</>;
-      case 'user-groups':              return <><i className="mr-1 icon-fw icon-people"></i>{          t('user_group_management.user_group_management') }</>;
-      case 'audit-log':                return <><i className="mr-1 icon-fw icon-feed"></i>{            t('audit_log_management.audit_log')}</>;
-      case 'plugins':                  return <><i className="mr-1 icon-fw icon-puzzle"></i>{          t('plugins.plugins')}</>;
-      case 'search':                   return <><i className="mr-1 icon-fw icon-magnifier"></i>{       t('full_text_search_management.full_text_search_management') }</>;
-      case 'cloud':                    return <><i className="mr-1 icon-fw icon-share-alt"></i>{       t('cloud_setting_management.to_cloud_settings')} </>;
-      default:                         return <><i className="mr-1 icon-fw icon-home"></i>{            t('wiki_management_home_page') }</>;
-      /* eslint-enable no-multi-spaces, max-len */
-    }
-  };
-
-  const MenuLink = ({
-    // eslint-disable-next-line react/prop-types
-    menu, isRoot, isListGroupItems, isActive,
-  }) => {
-    const pageTransitionClassName = isListGroupItems
-      ? 'list-group-item list-group-item-action border-0 round-corner'
-      : 'dropdown-item px-3 py-2';
-
-    const href = isRoot ? '/admin' : urljoin('/admin', menu);
-
-    return (
-      <Link
-        href={href}
-        className={`${pageTransitionClassName} ${isActive ? 'active' : ''}`}
-      >
-        <MenuLabel menu={menu} />
-      </Link>
-    );
-  };
-
-  const isActiveMenu = (path) => {
-    const basisPath = pathUtils.normalizePath(urljoin('/admin', path));
-    const basisParentPath = pathUtils.addTrailingSlash(basisPath);
-
-    return (
-      pathname === basisPath
-      || pathname.startsWith(basisParentPath)
-    );
-  };
-
-  const getListGroupItemOrDropdownItemList = (isListGroupItems) => {
-    return (
-      <>
-        {/* eslint-disable no-multi-spaces */}
-        <MenuLink menu="home"         isListGroupItems isActive={pathname === '/admin'} isRoot />
-        <MenuLink menu="app"          isListGroupItems isActive={isActiveMenu('/app')} />
-        <MenuLink menu="security"     isListGroupItems isActive={isActiveMenu('/security')} />
-        <MenuLink menu="markdown"     isListGroupItems isActive={isActiveMenu('/markdown')} />
-        <MenuLink menu="customize"    isListGroupItems isActive={isActiveMenu('/customize')} />
-        <MenuLink menu="importer"     isListGroupItems isActive={isActiveMenu('/importer')} />
-        <MenuLink menu="export"       isListGroupItems isActive={isActiveMenu('/export')} />
-        <MenuLink menu="data-transfer" isListGroupItems isActive={isActiveMenu('/data-transfer')} />
-        <MenuLink menu="notification" isListGroupItems isActive={isActiveMenu('/notification') || isActiveMenu('/global-notification')} />
-        <MenuLink menu="slack-integration" isListGroupItems isActive={isActiveMenu('/slack-integration')} />
-        <MenuLink menu="slack-integration-legacy" isListGroupItems isActive={isActiveMenu('/slack-integration-legacy')} />
-        <MenuLink menu="users"        isListGroupItems isActive={isActiveMenu('/users')} />
-        <MenuLink menu="user-groups"  isListGroupItems isActive={isActiveMenu('/user-groups')} />
-        <MenuLink menu="audit-log"    isListGroupItems isActive={isActiveMenu('/audit-log')} />
-        <MenuLink menu="plugins"      isListGroupItems isActive={isActiveMenu('/plugins')} />
-        <MenuLink menu="search"       isListGroupItems isActive={isActiveMenu('/search')} />
-        {growiCloudUri != null && growiAppIdForGrowiCloud != null
-          && (
-            <a
-              href={`${growiCloudUri}/my/apps/${growiAppIdForGrowiCloud}`}
-              className="list-group-item list-group-item-action border-0 round-corner"
-            >
-              <MenuLabel menu="cloud" />
-            </a>
-          )
-        }
-        {/* eslint-enable no-multi-spaces */}
-      </>
-    );
-  };
-
-  return (
-    <React.Fragment>
-      {/* List group */}
-      <div className="list-group admin-navigation sticky-top d-none d-lg-block">
-        {getListGroupItemOrDropdownItemList(true)}
-      </div>
-
-      {/* Dropdown */}
-      <div className="dropdown d-block d-lg-none mb-5">
-        <button
-          className="btn btn-outline-primary btn-lg dropdown-toggle col-12 text-right"
-          type="button"
-          id="dropdown-admin-navigation"
-          data-display="static"
-          data-toggle="dropdown"
-          aria-haspopup="true"
-          aria-expanded="false"
-        >
-          <span className="float-left">
-            {/* eslint-disable no-multi-spaces */}
-            {pathname === '/admin' &&              <MenuLabel menu="home" />}
-            {isActiveMenu('/app') &&               <MenuLabel menu="app" />}
-            {isActiveMenu('/security') &&          <MenuLabel menu="security" />}
-            {isActiveMenu('/markdown') &&          <MenuLabel menu="markdown" />}
-            {isActiveMenu('/customize') &&         <MenuLabel menu="customize" />}
-            {isActiveMenu('/importer') &&          <MenuLabel menu="importer" />}
-            {isActiveMenu('/export') &&            <MenuLabel menu="export" />}
-            {(isActiveMenu('/notification') || isActiveMenu('/global-notification')) && <MenuLabel menu="notification" />}
-            {isActiveMenu('/slack-integration') && <MenuLabel menu="slack-integration" />}
-            {isActiveMenu('/users') &&             <MenuLabel menu="users" />}
-            {isActiveMenu('/user-groups') &&       <MenuLabel menu="user-groups" />}
-            {isActiveMenu('/search') &&            <MenuLabel menu="search" />}
-            {isActiveMenu('/audit-log') &&         <MenuLabel menu="audit-log" />}
-            {isActiveMenu('/plugins') &&           <MenuLabel menu="plugins" />}
-            {isActiveMenu('/data-transfer') &&     <MenuLabel menu="data-transfer" />}
-            {/* eslint-enable no-multi-spaces */}
-          </span>
-        </button>
-        <div className="dropdown-menu" aria-labelledby="dropdown-admin-navigation">
-          {getListGroupItemOrDropdownItemList(false)}
-        </div>
-      </div>
-
-    </React.Fragment>
-  );
-};
-
-// const AdminNavigationWrapper = withUnstatedContainers(AdminNavigation, [AppContainer]);
-
-AdminNavigation.propTypes = {
-  // appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-};
-
-// export default AdminNavigationWrapper;
-export default AdminNavigation;

+ 167 - 0
apps/app/src/components/Admin/Common/AdminNavigation.tsx

@@ -0,0 +1,167 @@
+import React, { useCallback } from 'react';
+
+import { pathUtils } from '@growi/core';
+import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
+import urljoin from 'url-join';
+
+import { useGrowiCloudUri, useGrowiAppIdForGrowiCloud } from '../../../stores/context';
+
+// eslint-disable-next-line react/prop-types
+const MenuLabel = ({ menu }: { menu: string }) => {
+  const { t } = useTranslation(['admin', 'commons']);
+
+  switch (menu) {
+    /* eslint-disable no-multi-spaces, max-len */
+    case 'app':                      return <><i className="mr-1 icon-fw icon-settings"></i>{        t('headers.app_settings', { ns: 'commons' }) }</>;
+    case 'security':                 return <><i className="mr-1 icon-fw icon-shield"></i>{          t('security_settings.security_settings') }</>;
+    case 'markdown':                 return <><i className="mr-1 icon-fw icon-note"></i>{            t('markdown_settings.markdown_settings') }</>;
+    case 'customize':                return <><i className="mr-1 icon-fw icon-wrench"></i>{          t('customize_settings.customize_settings') }</>;
+    case 'importer':                 return <><i className="mr-1 icon-fw icon-cloud-upload"></i>{    t('importer_management.import_data') }</>;
+    case 'export':                   return <><i className="mr-1 icon-fw icon-cloud-download"></i>{  t('export_management.export_archive_data') }</>;
+    case 'data-transfer':            return <><i className="mr-1 icon-fw icon-plane"></i>{           t('g2g_data_transfer.data_transfer', { ns: 'commons' })}</>;
+    case 'notification':             return <><i className="mr-1 icon-fw icon-bell"></i>{            t('external_notification.external_notification')}</>;
+    case 'slack-integration':        return <><i className="mr-1 icon-fw icon-shuffle"></i>{         t('slack_integration.slack_integration') }</>;
+    case 'slack-integration-legacy': return <><i className="mr-1 icon-fw icon-shuffle"></i>{         t('slack_integration_legacy.slack_integration_legacy')}</>;
+    case 'users':                    return <><i className="mr-1 icon-fw icon-user"></i>{            t('user_management.user_management') }</>;
+    case 'user-groups':              return <><i className="mr-1 icon-fw icon-people"></i>{          t('user_group_management.user_group_management') }</>;
+    case 'audit-log':                return <><i className="mr-1 icon-fw icon-feed"></i>{            t('audit_log_management.audit_log')}</>;
+    case 'plugins':                  return <><i className="mr-1 icon-fw icon-puzzle"></i>{          t('plugins.plugins')}</>;
+    case 'search':                   return <><i className="mr-1 icon-fw icon-magnifier"></i>{       t('full_text_search_management.full_text_search_management') }</>;
+    case 'cloud':                    return <><i className="mr-1 icon-fw icon-share-alt"></i>{       t('cloud_setting_management.to_cloud_settings')} </>;
+    default:                         return <><i className="mr-1 icon-fw icon-home"></i>{            t('wiki_management_home_page') }</>;
+      /* eslint-enable no-multi-spaces, max-len */
+  }
+};
+
+type MenuLinkProps = {
+  menu: string,
+  isListGroupItems: boolean,
+  isRoot?: boolean,
+  isActive?: boolean,
+}
+
+const MenuLink = ({
+  menu, isRoot, isListGroupItems, isActive,
+}: MenuLinkProps) => {
+
+  const pageTransitionClassName = isListGroupItems
+    ? 'list-group-item list-group-item-action border-0 round-corner'
+    : 'dropdown-item px-3 py-2';
+
+  const href = isRoot ? '/admin' : urljoin('/admin', menu);
+
+  return (
+    <Link
+      href={href}
+      className={`${pageTransitionClassName} ${isActive ? 'active' : ''}`}
+    >
+      <MenuLabel menu={menu} />
+    </Link>
+  );
+};
+
+export const AdminNavigation = (): JSX.Element => {
+  const pathname = window.location.pathname;
+
+  const { data: growiCloudUri } = useGrowiCloudUri();
+  const { data: growiAppIdForGrowiCloud } = useGrowiAppIdForGrowiCloud();
+
+  const isActiveMenu = useCallback((path: string | string[]) => {
+    const paths = Array.isArray(path) ? path : [path];
+
+    return paths.some((path) => {
+      const basisPath = pathUtils.normalizePath(urljoin('/admin', path));
+      const basisParentPath = pathUtils.addTrailingSlash(basisPath);
+
+      return (
+        pathname === basisPath
+        || pathname.startsWith(basisParentPath)
+      );
+    });
+
+  }, [pathname]);
+
+  const getListGroupItemOrDropdownItemList = (isListGroupItems: boolean) => {
+    return (
+      <>
+        {/* eslint-disable no-multi-spaces */}
+        <MenuLink menu="home"                       isListGroupItems={isListGroupItems} isActive={pathname === '/admin'} isRoot />
+        <MenuLink menu="app"                        isListGroupItems={isListGroupItems} isActive={isActiveMenu('/app')} />
+        <MenuLink menu="security"                   isListGroupItems={isListGroupItems} isActive={isActiveMenu('/security')} />
+        <MenuLink menu="markdown"                   isListGroupItems={isListGroupItems} isActive={isActiveMenu('/markdown')} />
+        <MenuLink menu="customize"                  isListGroupItems={isListGroupItems} isActive={isActiveMenu('/customize')} />
+        <MenuLink menu="importer"                   isListGroupItems={isListGroupItems} isActive={isActiveMenu('/importer')} />
+        <MenuLink menu="export"                     isListGroupItems={isListGroupItems} isActive={isActiveMenu('/export')} />
+        <MenuLink menu="data-transfer"              isListGroupItems={isListGroupItems} isActive={isActiveMenu('/data-transfer')} />
+        <MenuLink menu="notification"               isListGroupItems={isListGroupItems} isActive={isActiveMenu(['/notification', '/global-notification'])} />
+        <MenuLink menu="slack-integration"          isListGroupItems={isListGroupItems} isActive={isActiveMenu('/slack-integration')} />
+        <MenuLink menu="slack-integration-legacy"   isListGroupItems={isListGroupItems} isActive={isActiveMenu('/slack-integration-legacy')} />
+        <MenuLink menu="users"                      isListGroupItems={isListGroupItems} isActive={isActiveMenu('/users')} />
+        <MenuLink menu="user-groups"                isListGroupItems={isListGroupItems} isActive={isActiveMenu(['/user-groups', 'user-group-detail'])} />
+        <MenuLink menu="audit-log"                  isListGroupItems={isListGroupItems} isActive={isActiveMenu('/audit-log')} />
+        <MenuLink menu="plugins"                    isListGroupItems={isListGroupItems} isActive={isActiveMenu('/plugins')} />
+        <MenuLink menu="search"                     isListGroupItems={isListGroupItems} isActive={isActiveMenu('/search')} />
+        {growiCloudUri != null && growiAppIdForGrowiCloud != null
+          && (
+            <a
+              href={`${growiCloudUri}/my/apps/${growiAppIdForGrowiCloud}`}
+              className="list-group-item list-group-item-action border-0 round-corner"
+            >
+              <MenuLabel menu="cloud" />
+            </a>
+          )
+        }
+        {/* eslint-enable no-multi-spaces */}
+      </>
+    );
+  };
+
+  return (
+    <React.Fragment>
+      {/* List group */}
+      <div className="list-group admin-navigation sticky-top d-none d-lg-block">
+        {getListGroupItemOrDropdownItemList(true)}
+      </div>
+
+      {/* Dropdown */}
+      <div className="dropdown d-block d-lg-none mb-5">
+        <button
+          className="btn btn-outline-primary btn-lg dropdown-toggle col-12 text-right"
+          type="button"
+          id="dropdown-admin-navigation"
+          data-display="static"
+          data-toggle="dropdown"
+          aria-haspopup="true"
+          aria-expanded="false"
+        >
+          <span className="float-left">
+            {/* eslint-disable no-multi-spaces */}
+            {pathname === '/admin'                  && <MenuLabel menu="home" />}
+            {isActiveMenu('/app')                   && <MenuLabel menu="app" />}
+            {isActiveMenu('/security')              && <MenuLabel menu="security" />}
+            {isActiveMenu('/markdown')              && <MenuLabel menu="markdown" />}
+            {isActiveMenu('/customize')             && <MenuLabel menu="customize" />}
+            {isActiveMenu('/importer')              && <MenuLabel menu="importer" />}
+            {isActiveMenu('/export')                && <MenuLabel menu="export" />}
+            {(isActiveMenu(['/notification', '/global-notification']))
+                                                    && <MenuLabel menu="notification" />}
+            {isActiveMenu('/slack-integration')     && <MenuLabel menu="slack-integration" />}
+            {isActiveMenu('/users')                 && <MenuLabel menu="users" />}
+            {isActiveMenu(['/user-groups', 'user-group-detail'])
+                                                    && <MenuLabel menu="user-groups" />}
+            {isActiveMenu('/search')                && <MenuLabel menu="search" />}
+            {isActiveMenu('/audit-log')             && <MenuLabel menu="audit-log" />}
+            {isActiveMenu('/plugins')               && <MenuLabel menu="plugins" />}
+            {isActiveMenu('/data-transfer')         && <MenuLabel menu="data-transfer" />}
+            {/* eslint-enable no-multi-spaces */}
+          </span>
+        </button>
+        <div className="dropdown-menu" aria-labelledby="dropdown-admin-navigation">
+          {getListGroupItemOrDropdownItemList(false)}
+        </div>
+      </div>
+
+    </React.Fragment>
+  );
+};

+ 3 - 2
apps/app/src/components/Admin/Notification/ManageGlobalNotification.tsx

@@ -3,6 +3,7 @@ import React, {
 } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
 import { useRouter } from 'next/router';
 
 import { NotifyType, TriggerEventType } from '~/client/interfaces/global-notification';
@@ -111,10 +112,10 @@ const ManageGlobalNotification = (props: Props): JSX.Element => {
   return (
     <>
       <div className="my-3">
-        <a href="/admin/notification#global-notification" className="btn btn-outline-secondary">
+        <Link href="/admin/notification" className="btn btn-outline-secondary">
           <i className="icon-fw ti ti-arrow-left" aria-hidden="true"></i>
           {t('notification_settings.back_to_list')}
-        </a>
+        </Link>
       </div>
 
 

+ 3 - 2
apps/app/src/components/Admin/UserGroup/UserGroupTable.tsx

@@ -5,6 +5,7 @@ import React, {
 import type { IUserGroupHasId, IUserGroupRelation, IUserHasId } from '@growi/core';
 import dateFnsFormat from 'date-fns/format';
 import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
 
 
 type Props = {
@@ -147,7 +148,7 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
               <tr key={group._id}>
                 {props.isAclEnabled
                   ? (
-                    <td><a href={`/admin/user-group-detail/${group._id}`}>{group.name}</a></td>
+                    <td><Link href={`/admin/user-group-detail/${group._id}`}>{group.name}</Link></td>
                   )
                   : (
                     <td>{group.name}</td>
@@ -168,7 +169,7 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
                         <li key={group._id} className="list-inline-item badge badge-success">
                           {props.isAclEnabled
                             ? (
-                              <a href={`/admin/user-group-detail/${group._id}`}>{group.name}</a>
+                              <Link href={`/admin/user-group-detail/${group._id}`}>{group.name}</Link>
                             )
                             : (
                               <p>{group.name}</p>

+ 2 - 2
apps/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -334,7 +334,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
       <nav aria-label="breadcrumb">
         <ol className="breadcrumb">
           <li className="breadcrumb-item">
-            <Link href="/admin/user-groups" prefetch={false}>
+            <Link href="/admin/user-groups">
               {t('user_group_management.group_list')}
             </Link>
           </li>
@@ -348,7 +348,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
                 { ancestorUserGroup._id === currentUserGroupId ? (
                   <span>{ancestorUserGroup.name}</span>
                 ) : (
-                  <Link href={`/admin/user-group-detail/${ancestorUserGroup._id}`} prefetch={false}>
+                  <Link href={`/admin/user-group-detail/${ancestorUserGroup._id}`}>
                     {ancestorUserGroup.name}
                   </Link>
                 ) }

+ 6 - 2
apps/app/src/components/Admin/UserGroupDetail/UserGroupPageList.tsx

@@ -49,8 +49,12 @@ const UserGroupPageList = (props: Props): JSX.Element => {
 
   return (
     <>
-      <ul className="page-list-ul page-list-ul-flat mb-3">
-        {currentPages.map(page => <li key={page._id}><PageListItemS page={page} /></li>)}
+      <ul className="page-list-ul page-list-ul-flat mt-3 mb-3">
+        { currentPages.map(page => (
+          <li key={page._id} className="mt-2">
+            <PageListItemS page={page} />
+          </li>
+        )) }
       </ul>
       {relatedPages != null && relatedPages.length === 0 ? <p>{t('user_group_management.no_pages')}</p> : (
         <PaginationWrapper

+ 3 - 2
apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx

@@ -121,7 +121,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
     }
   };
 
-  const isDropable = (item: DragItemDataType, type: string | null| symbol): boolean => {
+  const isDropable = (item: DragItemDataType, type: string | null | symbol): boolean => {
     if (type === DRAG_ITEM_TYPE.FOLDER) {
       if (item.bookmarkFolder.parent === bookmarkFolder._id || item.bookmarkFolder._id === bookmarkFolder._id) {
         return false;
@@ -143,6 +143,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
     return true;
   };
 
+
   const renderChildFolder = () => {
     return isOpen && children?.map((childFolder) => {
       return (
@@ -256,7 +257,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
               </div>
             </>
           )}
-          { isOperable && (
+          {isOperable && (
             <div className="grw-foldertree-control d-flex">
               <BookmarkFolderItemControl
                 onClickRename={onClickRenameHandler}

+ 3 - 3
apps/app/src/components/Bookmarks/BookmarkFolderMenu.tsx

@@ -120,7 +120,7 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
           className={'grw-bookmark-folder-menu-item text-danger'}
         >
           <i className="fa fa-bookmark"></i>{' '}
-          <span className="mx-2 ">
+          <span className="mx-2">
             {t('bookmark_folder.cancel_bookmark')}
           </span>
         </DropdownItem>
@@ -143,7 +143,7 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
               </div>
             </div>
             {bookmarkFolders?.map(folder => (
-              <div key={folder._id}>
+              <React.Fragment key={`bookmark-folders-${folder._id}`}>
                 <div
                   className="dropdown-item grw-bookmark-folder-menu-item list-group-item list-group-item-action border-0 py-0"
                   style={{ paddingLeft: '40px' }}
@@ -174,7 +174,7 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
                     </div>
                   </div>
                 ))}
-              </div>
+              </React.Fragment>
             ))}
           </>
         )}

+ 10 - 6
apps/app/src/components/Bookmarks/BookmarkFolderTree.tsx

@@ -2,6 +2,7 @@
 import React, { useCallback } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import { useRouter } from 'next/router';
 
 import { toastSuccess } from '~/client/util/toastr';
 import { IPageToDeleteWithMeta } from '~/interfaces/page';
@@ -12,7 +13,7 @@ import {
 import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
 import { useIsReadOnlyUser } from '~/stores/context';
 import { usePageDeleteModal } from '~/stores/modal';
-import { useSWRMUTxPageInfo, useSWRxCurrentPage } from '~/stores/page';
+import { mutateAllPageInfo, useSWRMUTxPageInfo, useSWRxCurrentPage } from '~/stores/page';
 
 import { BookmarkFolderItem } from './BookmarkFolderItem';
 import { BookmarkItem } from './BookmarkItem';
@@ -36,6 +37,7 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
 
   // const acceptedTypes: DragItemType[] = [DRAG_ITEM_TYPE.FOLDER, DRAG_ITEM_TYPE.BOOKMARK];
   const { t } = useTranslation();
+  const router = useRouter();
 
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: currentPage } = useSWRxCurrentPage();
@@ -55,13 +57,15 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
   const onClickDeleteMenuItemHandler = useCallback((pageToDelete: IPageToDeleteWithMeta) => {
     const pageDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, _isRecursively, isCompletely) => {
       if (typeof pathOrPathsToDelete !== 'string') return;
-
-      toastSuccess(isCompletely ? t('deleted_pages_completely', { pathOrPathsToDelete }) : t('deleted_pages', { pathOrPathsToDelete }));
-
+      toastSuccess(isCompletely ? t('deleted_pages_completely', { path: pathOrPathsToDelete }) : t('deleted_pages', { path: pathOrPathsToDelete }));
       bookmarkFolderTreeMutation();
+      mutateAllPageInfo();
+      if (pageToDelete.data._id === currentPage?._id && _isRecursively) {
+        router.push(`/trash${currentPage.path}`);
+      }
     };
     openDeleteModal([pageToDelete], { onDeleted: pageDeletedHandler });
-  }, [openDeleteModal, t, bookmarkFolderTreeMutation]);
+  }, [openDeleteModal, t, bookmarkFolderTreeMutation, currentPage?._id, currentPage?.path, router]);
 
   /* TODO: update in bookmarks folder v2. */
   // const itemDropHandler = async(item: DragItemDataType, dragType: string | null | symbol) => {
@@ -98,7 +102,7 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
   // };
 
   return (
-    <div className={`grw-folder-tree-container ${styles['grw-folder-tree-container']}` } >
+    <div className={`grw-folder-tree-container ${styles['grw-folder-tree-container']}`} >
       <ul className={`grw-foldertree ${styles['grw-foldertree']} list-group px-2 py-2`}>
         {bookmarkFolders?.map((bookmarkFolder) => {
           return (

+ 42 - 16
apps/app/src/components/Bookmarks/BookmarkItem.tsx

@@ -3,16 +3,19 @@ import React, { useCallback, useState } from 'react';
 import nodePath from 'path';
 
 import { DevidedPagePath, pathUtils } from '@growi/core';
+import { useRouter } from 'next/router';
 import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip, DropdownToggle } from 'reactstrap';
 
-import { bookmark, unbookmark } from '~/client/services/page-operation';
+
+import { bookmark, unbookmark, unlink } from '~/client/services/page-operation';
 import { addBookmarkToFolder, renamePage } from '~/client/util/bookmark-utils';
 import { ValidationTarget } from '~/client/util/input-validator';
-import { toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { BookmarkFolderItems, DragItemDataType, DRAG_ITEM_TYPE } from '~/interfaces/bookmark-info';
 import { IPageHasId, IPageInfoAll, IPageToDeleteWithMeta } from '~/interfaces/page';
-import { useSWRxPageInfo } from '~/stores/page';
+import { usePutBackPageModal } from '~/stores/modal';
+import { mutateAllPageInfo, useSWRMUTxCurrentPage, useSWRxPageInfo } from '~/stores/page';
 
 import ClosableTextInput from '../Common/ClosableTextInput';
 import { MenuItemType, PageItemControl } from '../Common/Dropdown/PageItemControl';
@@ -29,7 +32,7 @@ type Props = {
   parentFolder: BookmarkFolderItems | null,
   canMoveToRoot: boolean,
   onClickDeleteMenuItemHandler: (pageToDelete: IPageToDeleteWithMeta) => void,
-  bookmarkFolderTreeMutation: () => void
+  bookmarkFolderTreeMutation: () => void,
 }
 
 export const BookmarkItem = (props: Props): JSX.Element => {
@@ -37,15 +40,17 @@ export const BookmarkItem = (props: Props): JSX.Element => {
   const BASE_BOOKMARK_PADDING = 20;
 
   const { t } = useTranslation();
+  const router = useRouter();
 
   const {
     isReadOnlyUser, isOperable, bookmarkedPage, onClickDeleteMenuItemHandler,
     parentFolder, level, canMoveToRoot, bookmarkFolderTreeMutation,
   } = props;
-
+  const { open: openPutBackPageModal } = usePutBackPageModal();
   const [isRenameInputShown, setRenameInputShown] = useState(false);
 
   const { data: pageInfo, mutate: mutatePageInfo } = useSWRxPageInfo(bookmarkedPage._id);
+  const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
   const dPagePath = new DevidedPagePath(bookmarkedPage.path, false, true);
   const { latter: pageTitle, former: formerPagePath } = dPagePath;
   const bookmarkItemId = `bookmark-item-${bookmarkedPage._id}`;
@@ -116,6 +121,24 @@ export const BookmarkItem = (props: Props): JSX.Element => {
     onClickDeleteMenuItemHandler(pageToDelete);
   }, [bookmarkedPage._id, bookmarkedPage.path, bookmarkedPage.revision, onClickDeleteMenuItemHandler]);
 
+  const putBackClickHandler = useCallback(() => {
+    const { _id: pageId, path } = bookmarkedPage;
+    const putBackedHandler = async() => {
+      try {
+        await unlink(path);
+        mutateAllPageInfo();
+        bookmarkFolderTreeMutation();
+        router.push(`/${pageId}`);
+        mutateCurrentPage();
+        toastSuccess(t('page_has_been_reverted', { path }));
+      }
+      catch (err) {
+        toastError(err);
+      }
+    };
+    openPutBackPageModal({ pageId, path }, { onPutBacked: putBackedHandler });
+  }, [bookmarkedPage, openPutBackPageModal, bookmarkFolderTreeMutation, router, mutateCurrentPage, t]);
+
   return (
     <DragAndDropWrapper
       item={dragItem}
@@ -128,15 +151,17 @@ export const BookmarkItem = (props: Props): JSX.Element => {
         id={bookmarkItemId}
         style={{ paddingLeft }}
       >
-        { isRenameInputShown ? (
-          <ClosableTextInput
-            value={nodePath.basename(bookmarkedPage.path ?? '')}
-            placeholder={t('Input page name')}
-            onClickOutside={() => { setRenameInputShown(false) }}
-            onPressEnter={pressEnterForRenameHandler}
-            validationTarget={ValidationTarget.PAGE}
-          />
-        ) : <PageListItemS page={bookmarkedPage} pageTitle={pageTitle}/>}
+        { isRenameInputShown
+          ? (
+            <ClosableTextInput
+              value={nodePath.basename(bookmarkedPage.path ?? '')}
+              placeholder={t('Input page name')}
+              onClickOutside={() => { setRenameInputShown(false) }}
+              onPressEnter={pressEnterForRenameHandler}
+              validationTarget={ValidationTarget.PAGE}
+            />
+          )
+          : <PageListItemS page={bookmarkedPage} pageTitle={pageTitle} />}
 
         <div className='grw-foldertree-control'>
           <PageItemControl
@@ -148,8 +173,9 @@ export const BookmarkItem = (props: Props): JSX.Element => {
             onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
             onClickRenameMenuItem={renameMenuItemClickHandler}
             onClickDeleteMenuItem={deleteMenuItemClickHandler}
-            additionalMenuItemOnTopRenderer={canMoveToRoot && isOperable
-              ? () => <BookmarkMoveToRootBtn pageId={bookmarkedPage._id} onClickMoveToRootHandler={onClickMoveToRootHandler}/>
+            onClickRevertMenuItem={putBackClickHandler}
+            additionalMenuItemOnTopRenderer={canMoveToRoot
+              ? () => <BookmarkMoveToRootBtn pageId={bookmarkedPage._id} onClickMoveToRootHandler={onClickMoveToRootHandler} />
               : undefined}
           >
             <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover mr-1">

+ 1 - 6
apps/app/src/components/DescendantsPageList.tsx

@@ -10,9 +10,7 @@ import {
 } from '~/interfaces/page';
 import { IPagingResult } from '~/interfaces/paging-result';
 import { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
-import {
-  useIsGuestUser, useIsReadOnlyUser, useIsSharedUser,
-} from '~/stores/context';
+import { useIsGuestUser, useIsReadOnlyUser, useIsSharedUser } from '~/stores/context';
 import {
   mutatePageTree,
   useSWRxPageInfoForList, useSWRxPageList,
@@ -22,7 +20,6 @@ import { ForceHideMenuItems } from './Common/Dropdown/PageItemControl';
 import PageList from './PageList/PageList';
 import PaginationWrapper from './PaginationWrapper';
 
-
 type SubstanceProps = {
   pagingResult: IPagingResult<IPageHasId> | undefined,
   activePage: number,
@@ -71,7 +68,6 @@ const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element => {
     }
 
     mutatePageTree();
-
     if (onPagesDeleted != null) {
       onPagesDeleted(...args);
     }
@@ -81,7 +77,6 @@ const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element => {
     toastSuccess(t('page_has_been_reverted', { path }));
 
     mutatePageTree();
-
     if (onPagePutBacked != null) {
       onPagePutBacked(path);
     }

+ 1 - 1
apps/app/src/components/Layout/AdminLayout.tsx

@@ -2,6 +2,7 @@ import React, { ReactNode } from 'react';
 
 import dynamic from 'next/dynamic';
 
+import { AdminNavigation } from '../Admin/Common/AdminNavigation';
 import { GrowiNavbar } from '../Navbar/GrowiNavbar';
 
 import { RawLayout } from './RawLayout';
@@ -9,7 +10,6 @@ import { RawLayout } from './RawLayout';
 import styles from './Admin.module.scss';
 
 
-const AdminNavigation = dynamic(() => import('~/components/Admin/Common/AdminNavigation'), { ssr: false });
 const PageCreateModal = dynamic(() => import('../PageCreateModal'), { ssr: false });
 const SystemVersion = dynamic(() => import('../SystemVersion'), { ssr: false });
 const HotkeysManager = dynamic(() => import('../Hotkeys/HotkeysManager'), { ssr: false });

+ 2 - 0
apps/app/src/components/Layout/BasicLayout.tsx

@@ -16,6 +16,7 @@ const HotkeysManager = dynamic(() => import('../Hotkeys/HotkeysManager'), { ssr:
 const GrowiNavbarBottom = dynamic(() => import('../Navbar/GrowiNavbarBottom').then(mod => mod.GrowiNavbarBottom), { ssr: false });
 const ShortcutsModal = dynamic(() => import('../ShortcutsModal'), { ssr: false });
 const SystemVersion = dynamic(() => import('../SystemVersion'), { ssr: false });
+const PutbackPageModal = dynamic(() => import('../PutbackPageModal'), { ssr: false });
 // Page modals
 const PageCreateModal = dynamic(() => import('../PageCreateModal'), { ssr: false });
 const PageDuplicateModal = dynamic(() => import('../PageDuplicateModal'), { ssr: false });
@@ -59,6 +60,7 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
         <PageAccessoriesModal />
         <DeleteAttachmentModal />
         <DeleteBookmarkFolderModal />
+        <PutbackPageModal />
       </DndProvider>
 
       <PagePresentationModal />

+ 19 - 17
apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -25,7 +25,7 @@ import {
   usePageDuplicateModal, usePageRenameModal, usePageDeleteModal, usePagePresentationModal,
 } from '~/stores/modal';
 import {
-  useSWRMUTxCurrentPage, useSWRxTagsInfo, useCurrentPageId, useIsNotFound, useTemplateTagData,
+  useSWRMUTxCurrentPage, useSWRxTagsInfo, useCurrentPageId, useIsNotFound, useTemplateTagData, useSWRxPageInfo,
 } from '~/stores/page';
 import {
   EditorMode, useDrawerMode, useEditorMode, useIsAbleToShowPageManagement, useIsAbleToShowTagLabel,
@@ -98,7 +98,7 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
         <i className="icon-fw grw-page-control-dropdown-icon">
           <PresentationIcon />
         </i>
-        { t('Presentation Mode') }
+        {t('Presentation Mode')}
       </DropdownItem>
 
       {/* Export markdown */}
@@ -139,7 +139,7 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
         {t('attachment_data')}
       </DropdownItem>
 
-      { !isGuestUser && !isReadOnlyUser && !isSharedUser && (
+      {!isGuestUser && !isReadOnlyUser && !isSharedUser && (
         <NotAvailable isDisabled={isLinkSharingDisabled ?? false} title="Disabled by admin">
           <DropdownItem
             onClick={() => openAccessoriesModal(PageAccessoriesModalContents.ShareLink)}
@@ -152,7 +152,7 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
             {t('share_links.share_link_management')}
           </DropdownItem>
         </NotAvailable>
-      ) }
+      )}
     </>
   );
 };
@@ -179,7 +179,7 @@ const CreateTemplateMenuItems = (props: CreateTemplateMenuItemsProps): JSX.Eleme
         data-testid="open-page-template-modal-btn"
       >
         <i className="icon-fw icon-magic-wand grw-page-control-dropdown-icon"></i>
-        { t('template.option_label.create/edit') }
+        {t('template.option_label.create/edit')}
       </DropdownItem>
     </>
   );
@@ -231,6 +231,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const { open: openRenameModal } = usePageRenameModal();
   const { open: openDeleteModal } = usePageDeleteModal();
   const { data: templateTagData } = useTemplateTagData();
+  const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId);
 
   const updateStateAfterSave = useUpdateStateAfterSave(pageId);
 
@@ -319,9 +320,10 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
       }
 
       mutateCurrentPage();
+      mutatePageInfo();
     };
     openDeleteModal([pageWithMeta], { onDeleted: deletedHandler });
-  }, [currentPathname, mutateCurrentPage, openDeleteModal, router]);
+  }, [currentPathname, mutateCurrentPage, openDeleteModal, router, mutatePageInfo]);
 
   const switchContentWidthHandler = useCallback(async(pageId: string, value: boolean) => {
     if (!isSharedPage) {
@@ -341,9 +343,9 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
         return (
           <>
             {!isReadOnlyUser
-            && <CreateTemplateMenuItems
-              onClickTemplateMenuItem={templateMenuItemClickHandler}
-            />
+              && <CreateTemplateMenuItems
+                onClickTemplateMenuItem={templateMenuItemClickHandler}
+              />
             }
           </>);
       }
@@ -368,9 +370,9 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
       <>
         <div className="d-flex">
           <div className="d-flex flex-column align-items-end justify-content-center py-md-2" style={{ gap: `${isCompactMode ? '5px' : '7px'}` }}>
-            { isViewMode && (
+            {isViewMode && (
               <div className="h-50">
-                { pageId != null && (
+                {pageId != null && (
                   <SubNavButtons
                     isCompactMode={isCompactMode}
                     pageId={pageId}
@@ -386,9 +388,9 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
                     onClickDeleteMenuItem={deleteItemClickedHandler}
                     onClickSwitchContentWidth={switchContentWidthHandler}
                   />
-                ) }
+                )}
               </div>
-            ) }
+            )}
             {isAbleToChangeEditorMode && (
               <PageEditorModeManager
                 onPageEditorModeButtonClicked={viewType => mutateEditorMode(viewType)}
@@ -397,22 +399,22 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
               />
             )}
           </div>
-          { (isAbleToShowPageAuthors && !isCompactMode && !isUsersHomePage(path ?? '')) && (
+          {(isAbleToShowPageAuthors && !isCompactMode && !isUsersHomePage(path ?? '')) && (
             <ul className={`${AuthorInfoStyles['grw-author-info']} text-nowrap border-left d-none d-lg-block d-edit-none py-2 pl-4 mb-0 ml-3`}>
               <li className="pb-1">
-                { currentPage != null
+                {currentPage != null
                   ? <AuthorInfo user={currentPage.creator as IUser} date={currentPage.createdAt} mode="create" locate="subnav" />
                   : <AuthorInfoSkeleton />
                 }
               </li>
               <li className="mt-1 pt-1 border-top">
-                { currentPage != null
+                {currentPage != null
                   ? <AuthorInfo user={currentPage.lastUpdateUser as IUser} date={currentPage.updatedAt} mode="update" locate="subnav" />
                   : <AuthorInfoSkeleton />
                 }
               </li>
             </ul>
-          ) }
+          )}
         </div>
 
         {path != null && currentUser != null && !isReadOnlyUser && (

+ 6 - 3
apps/app/src/components/Navbar/SubNavButtons.tsx

@@ -202,8 +202,11 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
     sumOfLikers, sumOfSeenUsers, isLiked,
   } = pageInfo;
 
-  const forceHideMenuItemsWithBookmark = forceHideMenuItems ?? [];
-  forceHideMenuItemsWithBookmark.push(MenuItemType.BOOKMARK);
+  const forceHideMenuItemsWithAdditions = [
+    ...(forceHideMenuItems ?? []),
+    MenuItemType.BOOKMARK,
+    MenuItemType.REVERT,
+  ];
 
   return (
     <div className="d-flex" style={{ gap: '2px' }}>
@@ -244,7 +247,7 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
           pageInfo={pageInfo}
           isEnableActions={!isGuestUser}
           isReadOnlyUser={!!isReadOnlyUser}
-          forceHideMenuItems={forceHideMenuItemsWithBookmark}
+          forceHideMenuItems={forceHideMenuItemsWithAdditions}
           additionalMenuItemOnTopRenderer={!isReadOnlyUser ? additionalMenuItemOnTopRenderer : undefined}
           additionalMenuItemRenderer={additionalMenuItemRenderer}
           onClickRenameMenuItem={renameMenuItemClickHandler}

+ 4 - 4
apps/app/src/components/PageAlert/TrashPageAlert.tsx

@@ -89,7 +89,7 @@ export const TrashPageAlert = (): JSX.Element => {
           data-toggle="modal"
           data-testid="put-back-button"
         >
-          <i className="icon-action-undo" aria-hidden="true"></i> { t('Put Back') }
+          <i className="icon-action-undo" aria-hidden="true"></i> {t('Put Back')}
         </button>
         <button
           type="button"
@@ -97,7 +97,7 @@ export const TrashPageAlert = (): JSX.Element => {
           disabled={!(pageInfo?.isAbleToDeleteCompletely ?? false)}
           onClick={openPageDeleteModalHandler}
         >
-          <i className="icon-fire" aria-hidden="true"></i> { t('Delete Completely') }
+          <i className="icon-fire" aria-hidden="true"></i> {t('Delete Completely')}
         </button>
       </>
     );
@@ -115,11 +115,11 @@ export const TrashPageAlert = (): JSX.Element => {
           <br />
           <UserPicture user={deleteUser} />
           <span className="ml-2">
-            Deleted by { deleteUser?.name } at <span data-vrt-blackout-datetime>{deletedAt ?? pageData?.updatedAt}</span>
+            Deleted by {deleteUser?.name} at <span data-vrt-blackout-datetime>{deletedAt ?? pageData?.updatedAt}</span>
           </span>
         </div>
         <div className="pt-1 d-flex align-items-end align-items-lg-center">
-          { isAbleToShowTrashPageManagementButtons && renderTrashPageManagementButtons()}
+          {isAbleToShowTrashPageManagementButtons && renderTrashPageManagementButtons()}
         </div>
       </div>
     </>

+ 15 - 2
apps/app/src/components/PageEditor.tsx

@@ -83,7 +83,7 @@ const PageEditor = React.memo((): JSX.Element => {
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPage } = useSWRxCurrentPage();
   const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
-  const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
+  const { data: grantData } = useSelectedGrant();
   const { data: pageTags, sync: syncTagsInfoForEditor } = usePageTagsForEditors(pageId);
   const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
   const { data: editingMarkdown, mutate: mutateEditingMarkdown } = useEditingMarkdown();
@@ -110,7 +110,13 @@ const PageEditor = React.memo((): JSX.Element => {
 
   const updateStateAfterSave = useUpdateStateAfterSave(pageId, { supressEditingMarkdownMutation: true });
 
-  const currentRevisionId = currentPage?.revision?._id;
+  // TODO: remove workaround
+  // for https://redmine.weseek.co.jp/issues/125923
+  const [createdPageRevisionIdWithAttachment, setCreatedPageRevisionIdWithAttachment] = useState('');
+
+  // TODO: remove workaround
+  // for https://redmine.weseek.co.jp/issues/125923
+  const currentRevisionId = currentPage?.revision?._id ?? createdPageRevisionIdWithAttachment;
 
   const initialValue = useMemo(() => {
     if (!isNotFound) {
@@ -149,6 +155,12 @@ const PageEditor = React.memo((): JSX.Element => {
 
   }, [markdownToPreview, mutateIsConflict]);
 
+  // TODO: remove workaround
+  // for https://redmine.weseek.co.jp/issues/125923
+  useEffect(() => {
+    setCreatedPageRevisionIdWithAttachment('');
+  }, [router]);
+
   useEffect(() => {
     markdownToSave.current = initialValue;
     setMarkdownToPreview(initialValue);
@@ -327,6 +339,7 @@ const PageEditor = React.memo((): JSX.Element => {
         logger.info('Page is created', res.page._id);
         globalEmitter.emit('resetInitializedHackMdStatus');
         mutateIsLatestRevision(true);
+        setCreatedPageRevisionIdWithAttachment(res.page.revision);
         await mutateCurrentPageId(res.page._id);
         await mutateCurrentPage();
       }

+ 21 - 21
apps/app/src/components/PageList/PageListItemL.tsx

@@ -213,9 +213,9 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
                 linkedPagePath={linkedPagePathFormer}
                 linkedPagePathByHtml={linkedPagePathHighlightedFormer}
               />
-              { showPageUpdatedTime && (
+              {showPageUpdatedTime && (
                 <span className="page-list-updated-at text-muted">Last update: {lastUpdateDate}</span>
-              ) }
+              )}
             </div>
             <div className="d-flex align-items-center mb-1">
               {/* Picture */}
@@ -254,32 +254,32 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
 
               {/* doropdown icon includes page control buttons */}
               {hasBrowsingRights
-              && <div className="ml-auto">
-                <PageItemControl
-                  alignRight
-                  pageId={pageData._id}
-                  pageInfo={isIPageInfoForListing(pageMeta) ? pageMeta : undefined}
-                  isEnableActions={isEnableActions}
-                  isReadOnlyUser={isReadOnlyUser}
-                  forceHideMenuItems={forceHideMenuItems}
-                  onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
-                  onClickRenameMenuItem={renameMenuItemClickHandler}
-                  onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
-                  onClickDeleteMenuItem={deleteMenuItemClickHandler}
-                  onClickRevertMenuItem={revertMenuItemClickHandler}
-                />
-              </div>
+                && <div className="ml-auto">
+                  <PageItemControl
+                    alignRight
+                    pageId={pageData._id}
+                    pageInfo={isIPageInfoForListing(pageMeta) ? pageMeta : undefined}
+                    isEnableActions={isEnableActions}
+                    isReadOnlyUser={isReadOnlyUser}
+                    forceHideMenuItems={forceHideMenuItems}
+                    onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
+                    onClickRenameMenuItem={renameMenuItemClickHandler}
+                    onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
+                    onClickDeleteMenuItem={deleteMenuItemClickHandler}
+                    onClickRevertMenuItem={revertMenuItemClickHandler}
+                  />
+                </div>
               }
             </div>
             <div className="page-list-snippet py-1">
               <Clamp lines={2}>
-                { elasticSearchResult != null && elasticSearchResult.snippet != null && (
+                {elasticSearchResult != null && elasticSearchResult.snippet != null && (
                   // eslint-disable-next-line react/no-danger
                   <div dangerouslySetInnerHTML={{ __html: elasticSearchResult.snippet }}></div>
-                ) }
-                { revisionShortBody != null && (
+                )}
+                {revisionShortBody != null && (
                   <div data-testid="revision-short-body-in-page-list-item-L">{revisionShortBody}</div>
-                ) }
+                )}
                 {
                   !hasBrowsingRights && (
                     <>

+ 6 - 3
apps/app/src/components/PageList/PageListItemS.tsx

@@ -3,6 +3,7 @@ import React from 'react';
 import { PageListMeta } from '@growi/ui/dist/components/PagePath/PageListMeta';
 import { PagePathLabel } from '@growi/ui/dist/components/PagePath/PagePathLabel';
 import { UserPicture } from '@growi/ui/dist/components/User/UserPicture';
+import Link from 'next/link';
 
 import { IPageHasId } from '~/interfaces/page';
 
@@ -10,18 +11,20 @@ import { IPageHasId } from '~/interfaces/page';
 type PageListItemSProps = {
   page: IPageHasId,
   noLink?: boolean,
-  pageTitle?: string
+  pageTitle?: string,
 }
 
 export const PageListItemS = (props: PageListItemSProps): JSX.Element => {
 
-  const { page, noLink = false, pageTitle } = props;
+  const {
+    page, noLink = false, pageTitle,
+  } = props;
 
   const path = pageTitle != null ? pageTitle : page.path;
 
   let pagePathElement = <PagePathLabel path={path} additionalClassNames={['mx-1']} />;
   if (!noLink) {
-    pagePathElement = <a className="text-break" href={page.path}>{pagePathElement}</a>;
+    pagePathElement = <Link href={`/${page._id}`} className="text-break" prefetch={false}>{pagePathElement}</Link>;
   }
 
   return (

+ 20 - 7
apps/app/src/components/ReactMarkdownComponents/NextLink.tsx

@@ -1,3 +1,4 @@
+import { pagePathUtils } from '@growi/core';
 import Link, { LinkProps } from 'next/link';
 
 import { useSiteUrl } from '~/stores/context';
@@ -22,6 +23,18 @@ const isExternalLink = (href: string, siteUrl: string | undefined): boolean => {
   }
 };
 
+const isCreatablePage = (href: string) => {
+  try {
+    const url = new URL(href);
+    const pathName = url.pathname;
+    return pagePathUtils.isCreatablePage(pathName);
+  }
+  catch (err) {
+    logger.debug(err);
+    return false;
+  }
+};
+
 type Props = Omit<LinkProps, 'href'> & {
   children: React.ReactNode,
   id?: string,
@@ -45,13 +58,6 @@ export const NextLink = (props: Props): JSX.Element => {
     Object.entries(rest).filter(([key]) => key.startsWith('data-')),
   );
 
-  // when href is an anchor link
-  if (isAnchorLink(href)) {
-    return (
-      <a id={id} href={href} className={className} {...dataAttributes}>{children}</a>
-    );
-  }
-
   if (isExternalLink(href, siteUrl)) {
     return (
       <a id={id} href={href} className={className} target="_blank" rel="noopener noreferrer" {...dataAttributes}>
@@ -60,6 +66,13 @@ export const NextLink = (props: Props): JSX.Element => {
     );
   }
 
+  // when href is an anchor link or not-creatable path
+  if (isAnchorLink(href) || !isCreatablePage(href)) {
+    return (
+      <a id={id} href={href} className={className} {...dataAttributes}>{children}</a>
+    );
+  }
+
   return (
     <Link {...rest} href={href} prefetch={false} legacyBehavior>
       <a href={href} className={className} {...dataAttributes}>{children}</a>

+ 0 - 1
apps/app/src/components/SearchPage/SearchResultList.tsx

@@ -18,7 +18,6 @@ import { mutateSearching } from '~/stores/search';
 import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 import { PageListItemL } from '../PageList/PageListItemL';
 
-
 type Props = {
   pages: IPageWithSearchMeta[],
   selectedPageId?: string,

+ 7 - 0
apps/app/src/components/TemplateModal/TemplateModal.module.scss

@@ -0,0 +1,7 @@
+@use '~/styles/bootstrap/init' as bs;
+
+.dm-templates :global {
+  .dropdown-item:not(:first-child) {
+    border-top: 1px solid bs.$border-color;
+  }
+}

+ 62 - 32
apps/app/src/components/TemplateModal/TemplateModal.tsx

@@ -30,6 +30,10 @@ import Preview from '../PageEditor/Preview';
 
 import { useFormatter } from './use-formatter';
 
+
+import styles from './TemplateModal.module.scss';
+
+
 const logger = loggerFactory('growi:components:TemplateModal');
 
 
@@ -39,7 +43,7 @@ function constructTemplateId(templateSummary: TemplateSummary): string {
   return `${defaultTemplate.pluginId ?? ''}_${defaultTemplate.id}`;
 }
 
-type TemplateItemProps = {
+type TemplateSummaryItemProps = {
   templateSummary: TemplateSummary,
   selectedLocale?: string,
   onClick?: () => void,
@@ -47,7 +51,7 @@ type TemplateItemProps = {
   usersDefaultLang?: Lang,
 }
 
-const TemplateItem: React.FC<TemplateItemProps> = ({
+const TemplateListGroupItem: React.FC<TemplateSummaryItemProps> = ({
   templateSummary,
   onClick,
   isSelected,
@@ -73,6 +77,32 @@ const TemplateItem: React.FC<TemplateItemProps> = ({
   );
 };
 
+
+const TemplateDropdownItem: React.FC<TemplateSummaryItemProps> = ({
+  templateSummary,
+  onClick,
+  usersDefaultLang,
+}) => {
+
+  const localizedTemplate = getLocalizedTemplate(templateSummary, usersDefaultLang);
+  const templateLocales = extractSupportedLocales(templateSummary);
+
+  assert(localizedTemplate?.isValid);
+
+  return (
+    <DropdownItem
+      onClick={onClick}
+      className="px-4 py-3"
+    >
+      <h4 className="mb-1 text-wrap">{localizedTemplate.title}</h4>
+      <p className="mb-1 text-wrap">{localizedTemplate.desc}</p>
+      { templateLocales != null && Array.from(templateLocales).map(locale => (
+        <span key={locale} className="badge border rounded-pill text-muted mr-1">{locale}</span>
+      ))}
+    </DropdownItem>
+  );
+};
+
 type TemplateModalSubstanceProps = {
   templateModalStatus: TemplateModalStatus,
   close: () => void,
@@ -85,7 +115,7 @@ const TemplateModalSubstance = (props: TemplateModalSubstanceProps): JSX.Element
 
   const { data: personalSettingsInfo } = usePersonalSettings();
   const { data: rendererOptions } = usePreviewOptions();
-  const { data: templateSummaries } = useSWRxTemplates();
+  const { data: templateSummaries, isLoading } = useSWRxTemplates();
 
   const [selectedTemplateSummary, setSelectedTemplateSummary] = useState<TemplateSummary>();
   const [selectedTemplateLocale, setSelectedTemplateLocale] = useState<string>();
@@ -138,12 +168,8 @@ const TemplateModalSubstance = (props: TemplateModalSubstanceProps): JSX.Element
     }
   }, [templateModalStatus.isOpened]);
 
-  if (templateSummaries == null) {
-    return <></>;
-  }
-
   return (
-    <>
+    <div data-testid='template-modal'>
       <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
         {t('template.modal_label.Select template')}
       </ModalHeader>
@@ -151,13 +177,20 @@ const TemplateModalSubstance = (props: TemplateModalSubstanceProps): JSX.Element
         <div className="row">
           {/* List Group */}
           <div className="d-none d-lg-block col-lg-4">
+
+            { isLoading && (
+              <div className='h-100 d-flex justify-content-center align-items-center'>
+                <i className="fa fa-2x fa-spinner fa-pulse text-muted mx-auto"></i>
+              </div>
+            ) }
+
             <div className="list-group">
-              {templateSummaries.map((templateSummary) => {
+              { templateSummaries != null && templateSummaries.map((templateSummary) => {
                 const templateId = constructTemplateId(templateSummary);
                 const isSelected = selectedTemplateSummary != null && constructTemplateId(selectedTemplateSummary) === templateId;
 
                 return (
-                  <TemplateItem
+                  <TemplateListGroupItem
                     key={templateId}
                     templateSummary={templateSummary}
                     onClick={() => onClickHandler(templateSummary)}
@@ -165,41 +198,38 @@ const TemplateModalSubstance = (props: TemplateModalSubstanceProps): JSX.Element
                     usersDefaultLang={usersDefaultLang}
                   />
                 );
-              })}
+              }) }
             </div>
           </div>
           {/* Dropdown */}
           <div className='d-lg-none col mb-3'>
             <UncontrolledDropdown>
-              <DropdownToggle caret type="button" outline className='w-100 text-right'>
+              <DropdownToggle caret type="button" outline className='w-100 text-right' disabled={isLoading}>
                 <span className="float-left">
-                  {selectedLocalizedTemplate != null && selectedLocalizedTemplate.isValid
-                    ? selectedLocalizedTemplate.title
-                    : t('Select template')}
+                  { (() => {
+                    if (isLoading) {
+                      return 'Loading..';
+                    }
+
+                    return selectedLocalizedTemplate != null && selectedLocalizedTemplate.isValid
+                      ? selectedLocalizedTemplate.title
+                      : t('Select template');
+                  })() }
                 </span>
               </DropdownToggle>
-              <DropdownMenu role="menu" className='p-0'>
-                {templateSummaries.map((templateSummary, index) => {
+              <DropdownMenu role="menu" className={`p-0 ${styles['dm-templates']}`}>
+                { templateSummaries != null && templateSummaries.map((templateSummary) => {
                   const templateId = constructTemplateId(templateSummary);
-                  const localizedTemplate = getLocalizedTemplate(templateSummary, usersDefaultLang);
-                  const templateLocales = extractSupportedLocales(templateSummary);
-
-                  assert(localizedTemplate?.isValid);
 
                   return (
-                    <DropdownItem
+                    <TemplateDropdownItem
                       key={templateId}
+                      templateSummary={templateSummary}
                       onClick={() => onClickHandler(templateSummary)}
-                      className={`px-4 py-3 ${index === 0 ? '' : 'border-top'}`}
-                    >
-                      <h4 className="mb-1 text-wrap">{localizedTemplate.title}</h4>
-                      <p className="mb-1 text-wrap">{localizedTemplate.desc}</p>
-                      { templateLocales != null && Array.from(templateLocales).map(locale => (
-                        <span key={locale} className="badge border rounded-pill text-muted mr-1">{locale}</span>
-                      ))}
-                    </DropdownItem>
+                      usersDefaultLang={usersDefaultLang}
+                    />
                   );
-                })}
+                }) }
               </DropdownMenu>
             </UncontrolledDropdown>
           </div>
@@ -249,7 +279,7 @@ const TemplateModalSubstance = (props: TemplateModalSubstanceProps): JSX.Element
           {t('commons:Insert')}
         </button>
       </ModalFooter>
-    </>
+    </div>
   );
 };
 

+ 19 - 5
apps/app/src/features/templates/server/routes/apiv3/index.ts

@@ -41,18 +41,32 @@ module.exports = (crowi) => {
     // scan preset templates
     if (presetTemplateSummaries == null) {
       const presetTemplatesRoot = resolveFromRoot('../../node_modules/@growi/preset-templates');
-      presetTemplateSummaries = await scanAllTemplates(presetTemplatesRoot, {
-        returnsInvalidTemplates: includeInvalidTemplates,
-      });
+
+      try {
+        presetTemplateSummaries = await scanAllTemplates(presetTemplatesRoot, {
+          returnsInvalidTemplates: includeInvalidTemplates,
+        });
+      }
+      catch (err) {
+        logger.error(err);
+        presetTemplateSummaries = [];
+      }
     }
 
     // load plugin templates
-    const plugins = await GrowiPlugin.findEnabledPluginsByType(GrowiPluginType.Template);
+    let pluginsTemplateSummaries: TemplateSummary[] = [];
+    try {
+      const plugins = await GrowiPlugin.findEnabledPluginsByType(GrowiPluginType.Template);
+      pluginsTemplateSummaries = plugins.flatMap(p => p.meta.templateSummaries);
+    }
+    catch (err) {
+      logger.error(err);
+    }
 
     return res.apiv3({
       summaries: [
         ...presetTemplateSummaries,
-        ...plugins.flatMap(p => p.meta.templateSummaries),
+        ...pluginsTemplateSummaries,
       ],
     });
   });

+ 20 - 38
apps/app/src/pages/[[...path]].page.tsx

@@ -83,7 +83,7 @@ const QuestionnaireModalManager = dynamic(() => import('~/features/questionnaire
 const logger = loggerFactory('growi:pages:all');
 
 const {
-  isPermalink: _isPermalink, isTrashPage: _isTrashPage, isCreatablePage,
+  isPermalink: _isPermalink, isCreatablePage,
 } = pagePathUtils;
 const { removeHeadingSlash } = pathUtils;
 
@@ -129,11 +129,6 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   );
 };
 
-const PutbackPageModal = (): JSX.Element => {
-  const PutbackPageModal = dynamic(() => import('../components/PutbackPageModal'), { ssr: false });
-  return <PutbackPageModal />;
-};
-
 type Props = CommonProps & {
   pageWithMeta: IPageToShowRevisionWithMeta | null,
   // pageUser?: any,
@@ -238,37 +233,12 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   useHasDraftOnHackmd(pageWithMeta?.data.hasDraftOnHackmd ?? false);
   useCurrentPathname(props.currentPathname);
 
-  const { mutate: mutateInitialPage } = useSWRxCurrentPage();
+  useSWRxCurrentPage(pageWithMeta?.data ?? null); // store initial data
+
   const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
   const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
   const { data: currentPageId, mutate: mutateCurrentPageId } = useCurrentPageId();
 
-  // Store initial data
-  useEffect(() => {
-    if (!props.skipSSR) {
-      mutateInitialPage(pageWithMeta?.data ?? null);
-    }
-  }, [mutateInitialPage, pageWithMeta, props.skipSSR]);
-
-  // Store initial data (When revisionBody is not SSR)
-  useEffect(() => {
-    if (!props.skipSSR) {
-      return;
-    }
-
-    if (currentPageId != null && !props.isNotFound) {
-      const mutatePageData = async() => {
-        const pageData = await mutateCurrentPage();
-        mutateEditingMarkdown(pageData?.revision.body);
-      };
-
-      // If skipSSR is true, use the API to retrieve page data.
-      // Because pageWIthMeta does not contain revision.body
-      mutatePageData();
-    }
-  }, [currentPageId, mutateCurrentPage, mutateEditingMarkdown, props.isNotFound, props.skipSSR]);
-
-
   const { mutate: mutateIsNotFound } = useIsNotFound();
 
   const { mutate: mutateIsLatestRevision } = useIsLatestRevision();
@@ -287,9 +257,23 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
 
   const growiLayoutFluidClass = useCurrentGrowiLayoutFluidClassName(pageWithMeta?.data);
 
-  const shouldRenderPutbackPageModal = pageWithMeta != null
-    ? _isTrashPage(pageWithMeta.data.path)
-    : false;
+  // Store initial data (When revisionBody is not SSR)
+  useEffect(() => {
+    if (!props.skipSSR) {
+      return;
+    }
+
+    if (currentPageId != null && !props.isNotFound) {
+      const mutatePageData = async() => {
+        const pageData = await mutateCurrentPage();
+        mutateEditingMarkdown(pageData?.revision.body);
+      };
+
+      // If skipSSR is true, use the API to retrieve page data.
+      // Because pageWIthMeta does not contain revision.body
+      mutatePageData();
+    }
+  }, [currentPageId, mutateCurrentPage, mutateEditingMarkdown, props.isNotFound, props.skipSSR]);
 
   // sync grant data
   useEffect(() => {
@@ -371,8 +355,6 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
         />
 
         <PageStatusAlert />
-
-        {shouldRenderPutbackPageModal && <PutbackPageModal />}
       </div>
     </>
   );

+ 0 - 8
apps/app/src/pages/_search.page.tsx

@@ -3,7 +3,6 @@ import { ReactNode } from 'react';
 import type { GetServerSideProps, GetServerSidePropsContext } from 'next';
 import { useTranslation } from 'next-i18next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
-import dynamic from 'next/dynamic';
 import Head from 'next/head';
 
 import SearchResultLayout from '~/components/Layout/SearchResultLayout';
@@ -66,11 +65,6 @@ const SearchResultPage: NextPageWithLayout<Props> = (props: Props) => {
   useShowPageLimitationL(props.showPageLimitationL);
   useIsContainerFluid(props.isContainerFluid);
 
-  const PutbackPageModal = (): JSX.Element => {
-    const PutbackPageModal = dynamic(() => import('../components/PutbackPageModal'), { ssr: false });
-    return <PutbackPageModal />;
-  };
-
   const title = generateCustomTitle(props, t('search_result.title'));
 
   return (
@@ -82,8 +76,6 @@ const SearchResultPage: NextPageWithLayout<Props> = (props: Props) => {
       <div id="search-page" className="dynamic-layout-root">
         <SearchPage />
       </div>
-
-      <PutbackPageModal />
     </>
   );
 };

+ 0 - 2
apps/app/src/pages/trash.page.tsx

@@ -27,7 +27,6 @@ import {
 
 const TrashPageList = dynamic(() => import('~/components/TrashPageList').then(mod => mod.TrashPageList), { ssr: false });
 const EmptyTrashModal = dynamic(() => import('~/components/EmptyTrashModal'), { ssr: false });
-const PutbackPageModal = dynamic(() => import('~/components/PutbackPageModal'), { ssr: false });
 
 type Props = CommonProps & {
   currentUser: IUser,
@@ -107,7 +106,6 @@ TrashPage.getLayout = function getLayout(page) {
         {page}
       </Layout>
       <EmptyTrashModal />
-      <PutbackPageModal />
     </>
   );
 };

+ 19 - 5
apps/app/src/server/service/search-delegator/elasticsearch-client.ts

@@ -1,7 +1,12 @@
 /* eslint-disable implicit-arrow-linebreak */
 /* eslint-disable no-confusing-arrow */
-import { Client as ES7Client, ApiResponse as ES7ApiResponse, RequestParams as ES7RequestParams } from '@elastic/elasticsearch7';
-import { Client as ES8Client, estypes } from '@elastic/elasticsearch8';
+import {
+  Client as ES7Client,
+  ClientOptions as ES7ClientOptions,
+  ApiResponse as ES7ApiResponse,
+  RequestParams as ES7RequestParams,
+} from '@elastic/elasticsearch7';
+import { ClientOptions as ES8ClientOptions, Client as ES8Client, estypes } from '@elastic/elasticsearch8';
 
 import {
   BulkResponse,
@@ -17,12 +22,21 @@ import {
   ReindexResponse,
 } from './elasticsearch-client-types';
 
+
+type ElasticsearchClientParams =
+  | [ isES7: true, options: ES7ClientOptions, rejectUnauthorized: boolean ]
+  | [ isES7: false, options: ES8ClientOptions, rejectUnauthorized: boolean ]
+
 export default class ElasticsearchClient {
 
-  client: ES7Client | ES8Client;
+  private client: ES7Client | ES8Client;
+
+  constructor(...params: ElasticsearchClientParams) {
+    const [isES7, options, rejectUnauthorized] = params;
 
-  constructor(client: ES7Client | ES8Client) {
-    this.client = client;
+    this.client = isES7
+      ? new ES7Client({ ...options, ssl: { rejectUnauthorized } })
+      : new ES8Client({ ...options, tls: { rejectUnauthorized } });
   }
 
   async bulk(params: ES7RequestParams.Bulk & estypes.BulkRequest): Promise<BulkResponse | estypes.BulkResponse> {

+ 6 - 6
apps/app/src/server/service/search-delegator/elasticsearch.ts

@@ -1,8 +1,6 @@
 import { Writable, Transform } from 'stream';
 import { URL } from 'url';
 
-import elasticsearch7 from '@elastic/elasticsearch7';
-import elasticsearch8 from '@elastic/elasticsearch8';
 import gc from 'expose-gc/function';
 import mongoose from 'mongoose';
 import streamToPromise from 'stream-to-promise';
@@ -81,7 +79,6 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
 
     this.isElasticsearchV7 = elasticsearchVersion === 7;
 
-    this.elasticsearch = this.isElasticsearchV7 ? elasticsearch7 : elasticsearch8;
     this.isElasticsearchReindexOnBoot = this.configManager.getConfig('crowi', 'app:elasticsearchReindexOnBoot');
     this.client = null;
 
@@ -119,12 +116,15 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
   initClient() {
     const { host, auth, indexName } = this.getConnectionInfo();
 
-    this.client = new ElasticsearchClient(new this.elasticsearch.Client({
+    const rejectUnauthorized = this.configManager.getConfig('crowi', 'app:elasticsearchRejectUnauthorized');
+
+    const options = {
       node: host,
-      ssl: { rejectUnauthorized: this.configManager.getConfig('crowi', 'app:elasticsearchRejectUnauthorized') },
       auth,
       requestTimeout: this.configManager.getConfig('crowi', 'app:elasticsearchRequestTimeout'),
-    }));
+    };
+
+    this.client = new ElasticsearchClient(this.isElasticsearchV7, options, rejectUnauthorized);
     this.indexName = indexName;
   }
 

+ 1 - 1
apps/app/src/services/renderer/remark-plugins/attachment.ts

@@ -7,7 +7,7 @@ import { visit } from 'unist-util-visit';
 
 const SUPPORTED_ATTRIBUTES = ['attachmentId', 'url', 'attachmentName'];
 
-const isAttachmentLink = (url: string) => {
+const isAttachmentLink = (url: string): boolean => {
   // https://regex101.com/r/9qZhiK/1
   const attachmentUrlFormat = new RegExp(/^\/(attachment)\/([^/^\n]+)$/);
   return attachmentUrlFormat.test(url);

+ 19 - 17
apps/app/test/cypress/e2e/20-basic-features/20-basic-features--access-to-page.cy.ts

@@ -81,15 +81,16 @@ context('Access to page', () => {
     cy.screenshot(`${ssPrefix}-Sandbox-edit-page`);
   })
 
+  const body1 = 'hello';
+  const body2 = ' world!';
   it('View and Edit contents are successfully loaded', () => {
-    const body1 = 'hello';
     cy.visit('/Sandbox/testForUseEditingMarkdown');
 
     openEditor();
 
     // check edited contents after save
-    cy.get('.CodeMirror').type(body1);
-    cy.get('.CodeMirror').contains(body1);
+    cy.get('.CodeMirror textarea').type(body1, { force: true });
+    cy.get('.CodeMirror-code').contains(body1);
     cy.get('.page-editor-preview-body').contains(body1);
     cy.getByTestid('page-editor').should('be.visible');
     cy.getByTestid('save-page-btn').click();
@@ -98,22 +99,21 @@ context('Access to page', () => {
   })
 
   it('Editing contents are successfully loaded with shortcut key', () => {
-    const body2 = ' world!';
     const savePageShortcutKey = '{ctrl+s}';
 
     cy.visit('/Sandbox/testForUseEditingMarkdown');
 
     openEditor();
 
+    cy.get('.CodeMirror-code').contains(body1);
+
     // check editing contents with shortcut key
-    cy.get('.CodeMirror-line').children().first().invoke('text').then((text) => {
-      cy.get('.CodeMirror').type(body2);
-      cy.get('.CodeMirror').contains(text+body2);
-      cy.get('.page-editor-preview-body').contains(text+body2);
-      cy.get('.CodeMirror').type(savePageShortcutKey);
-      cy.get('.CodeMirror').contains(text+body2);
-      cy.get('.page-editor-preview-body').contains(text+body2);
-    })
+    cy.get('.CodeMirror textarea').type(body2, { force: true });
+    cy.get('.CodeMirror-code').contains(body1+body2);
+    cy.get('.page-editor-preview-body').contains(body1+body2);
+    cy.get('.CodeMirror').click().type(savePageShortcutKey);
+    cy.get('.CodeMirror-code').contains(body1+body2);
+    cy.get('.page-editor-preview-body').contains(body1+body2);
   })
 
   it('/user/admin is successfully loaded', () => {
@@ -233,9 +233,11 @@ context('Access to Template Editing Mode', () => {
     })
 
     cy.visit(`/${parentPagePath}/${newPagePath}`);
-    cy.waitUntilSkeletonDisappear();
     cy.collapseSidebar(true);
 
+    cy.getByTestid('grw-contextual-sub-nav').should('be.visible');
+    cy.waitUntilSkeletonDisappear();
+
     // Check if the template is applied
     cy.get('.content-main').within(() => {
       cy.get('.wiki').should('be.visible');
@@ -277,8 +279,8 @@ context('Access to Template Editing Mode', () => {
       cy.screenshot(`${ssPrefix}-open-template-page-for-children-in-editor-mode`);
     });
 
-    cy.get('.CodeMirror').type(templateBody1);
-    cy.get('.CodeMirror').contains(templateBody1);
+    cy.get('.CodeMirror textarea').type(templateBody1, { force: true });
+    cy.get('.CodeMirror-code').contains(templateBody1);
     cy.get('.page-editor-preview-body').contains(templateBody1);
     cy.getByTestid('page-editor').should('be.visible');
     cy.getByTestid('save-page-btn').click();
@@ -312,8 +314,8 @@ context('Access to Template Editing Mode', () => {
       cy.screenshot(`${ssPrefix}-open-template-page-for-descendants-in-editor-mode`);
     })
 
-    cy.get('.CodeMirror').type(templateBody2);
-    cy.get('.CodeMirror').contains(templateBody2);
+    cy.get('.CodeMirror textarea').type(templateBody2, { force: true });
+    cy.get('.CodeMirror-code').contains(templateBody2);
     cy.get('.page-editor-preview-body').contains(templateBody2);
     cy.getByTestid('page-editor').should('be.visible');
     cy.getByTestid('save-page-btn').click();

+ 1 - 0
apps/app/test/cypress/e2e/20-basic-features/20-basic-features--sticky-features.cy.ts

@@ -116,6 +116,7 @@ context('Access to any page', () => {
       return cy.get('.layout-root').then($elem => $elem.hasClass('editing'));
     });
     cy.get('.grw-editor-navbar-bottom').should('be.visible');
+    cy.get('.CodeMirror').should('be.visible');
     cy.screenshot(`${ssPrefix}open-editor-when-sticky`);
   });
 

+ 1 - 0
apps/app/test/cypress/e2e/22-sharelink/22-sharelink--access-to-sharelink.cy.ts

@@ -15,6 +15,7 @@ context('Access to sharelink by guest', () => {
     cy.waitUntil(() => {
       // do
       cy.getByTestid('grw-contextual-sub-nav').should('be.visible').within(() => {
+        cy.waitUntilSkeletonDisappear();
         cy.getByTestid('open-page-item-control-btn').find('button').first().as('btn').click();
       });
       // wait until

+ 90 - 0
apps/app/test/cypress/e2e/23-editor/23-editor--template-modal.cy.ts

@@ -0,0 +1,90 @@
+context('TemplateModal', () => {
+
+  const ssPrefix = 'template-modal-';
+
+  beforeEach(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+  });
+
+  it("TemplateModal is shown and closed successfully", () => {
+    cy.visit('/Sandbox/TemplateModal');
+    cy.collapseSidebar(true, true);
+
+    // move to edit mode
+    cy.get('#grw-page-editor-mode-manager').as('pageEditorModeManager').should('be.visible');
+    cy.waitUntil(() => {
+      // do
+      cy.get('@pageEditorModeManager').within(() => {
+        cy.get('button:nth-child(2)').click();
+      });
+      // until
+      return cy.get('.layout-root').then($elem => $elem.hasClass('editing'));
+    })
+    cy.get('.grw-editor-navbar-bottom').should('be.visible');
+
+    // show TemplateModal
+    cy.waitUntil(() => {
+      // do
+      cy.get('.navbar-editor > ul > li:nth-child(16) > button').click({force: true});
+      // wait until
+      return cy.getByTestid('template-modal').then($elem => $elem.is(':visible'));
+    });
+
+    // close TemplateModal
+    cy.getByTestid('template-modal').should('be.visible').within(() => {
+      cy.screenshot(`${ssPrefix}opened`);
+      cy.get('button.close').click();
+    });
+
+    cy.screenshot(`${ssPrefix}close`);
+  });
+
+  it("Successfully select template and template locale", () => {
+    cy.visit('/Sandbox/TemplateModal');
+    cy.collapseSidebar(true, true);
+
+    // move to edit mode
+    cy.get('#grw-page-editor-mode-manager').as('pageEditorModeManager').should('be.visible');
+    cy.waitUntil(() => {
+      // do
+      cy.get('@pageEditorModeManager').within(() => {
+        cy.get('button:nth-child(2)').click();
+      });
+      // until
+      return cy.get('.layout-root').then($elem => $elem.hasClass('editing'));
+    })
+    cy.get('.grw-editor-navbar-bottom').should('be.visible');
+
+    // show TemplateModal
+    cy.waitUntil(() => {
+      // do
+      cy.get('.navbar-editor > ul > li:nth-child(16) > button').click({force: true});
+      // wait until
+      return cy.getByTestid('template-modal').then($elem => $elem.is(':visible'));
+    });
+
+    // select template and template locale
+    cy.getByTestid('template-modal').should('be.visible').within(() => {
+      // select first template
+      cy.get('.list-group > .list-group-item:nth-child(1)').click();
+      // check preview exist
+      cy.get('.card-body > .page-editor-preview-body > .wiki').should('exist');
+      cy.screenshot(`${ssPrefix}select-template`);
+
+      // change template locale
+      cy.get('.modal-body > div:nth-child(1) > div:nth-child(3) > div:nth-child(1) > div:nth-child(2) > .dropdown > button').click();
+      cy.get('.modal-body > div:nth-child(1) > div:nth-child(3) > div:nth-child(1) > div:nth-child(2) > .dropdown > div > button:nth-child(2)').click();
+      cy.screenshot(`${ssPrefix}select-template-locale`);
+
+      // click insert button
+      cy.get('.modal-footer > button:nth-child(2)').click();
+    });
+
+    // check show template on markdown
+    cy.screenshot(`${ssPrefix}insert-template`);
+  });
+
+});

+ 31 - 8
apps/app/test/cypress/e2e/23-editor/23-editor--with-navigation.cy.ts

@@ -38,8 +38,8 @@ context('Editor while uploading to a new page', () => {
 
     // input the body
     const body = 'Hello World!';
-    cy.get('.CodeMirror').type(body + '\n\n');
-    cy.get('.CodeMirror').should('contain.text', body);
+    cy.get('.CodeMirror textarea').type(body + '\n\n', { force: true });
+    cy.get('.CodeMirror-code').should('contain.text', body);
 
     // open GrantSelector
     cy.waitUntil(() => {
@@ -64,21 +64,44 @@ context('Editor while uploading to a new page', () => {
     cy.getByTestid('grw-grant-selector').find('.dropdown-toggle').should('contain.text', 'Only me');
     cy.screenshot(`${ssPrefix}-prevent-grantselector-modified-2`);
 
+    // intercept API req/res for fixing labels
+    const dummyAttachmentId = '64b000000000000000000000';
+    let uploadedAttachmentId = '';
+    cy.intercept('POST', '/_api/attachments.add', (req) => {
+      req.continue((res) => {
+        // store the attachment id
+        uploadedAttachmentId = res.body.attachment._id;
+        // overwrite filePathProxied
+        res.body.attachment.filePathProxied = `/attachment/${dummyAttachmentId}`;
+      });
+    }).as('attachmentsAdd');
+    cy.intercept('GET', `/_api/v3/attachment?attachmentId=${dummyAttachmentId}`, (req) => {
+      // replace attachmentId query
+      req.url = req.url.replace(dummyAttachmentId, uploadedAttachmentId);
+      req.continue((res) => {
+        // overwrite the attachment createdAt
+        res.body.attachment.createdAt = new Date('2023-07-01T00:00:00');
+      });
+    });
+
     // drag-drop a file
-    cy.intercept('POST', '/_api/attachments.add').as('attachmentsAdd');
     const filePath = path.relative('/', path.resolve(Cypress.spec.relative, '../assets/example.txt'));
     cy.get('.dropzone').selectFile(filePath, { action: 'drag-drop' });
-    cy.wait('@attachmentsAdd')
+    cy.wait('@attachmentsAdd');
+
+    cy.screenshot(`${ssPrefix}-prevent-grantselector-modified-3`);
 
     // Update page using shortcut keys
-    cy.get('.CodeMirror').type('{ctrl+s}');
+    cy.get('.CodeMirror').click().type('{ctrl+s}');
+
+    cy.screenshot(`${ssPrefix}-prevent-grantselector-modified-4`);
 
     // expect
     cy.get('.Toastify__toast').should('contain.text', 'Saved successfully');
-    cy.get('.CodeMirror').should('contain.text', body);
-    cy.get('.CodeMirror').should('contain.text', '[example.txt](/attachment/');
+    cy.get('.CodeMirror-code').should('contain.text', body);
+    cy.get('.CodeMirror-code').should('contain.text', '[example.txt](/attachment/64b000000000000000000000');
     cy.getByTestid('grw-grant-selector').find('.dropdown-toggle').should('contain.text', 'Only me');
-    cy.screenshot(`${ssPrefix}-prevent-grantselector-modified-3`);
+    cy.screenshot(`${ssPrefix}-prevent-grantselector-modified-5`);
   });
 
 });

+ 2 - 2
apps/slackbot-proxy/package.json

@@ -1,10 +1,10 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "6.1.5-slackbot-proxy.0",
+  "version": "6.1.7-slackbot-proxy.0",
   "license": "MIT",
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
-    "clean": "npx -y shx rm -rf dist",
+    "clean": "shx rm -rf dist",
     "cp:public": "cp -RT ./src/public ./dist/public",
     "cp:views": "cp -RT ./src/views ./dist/views",
     "cp:bootstrap": "cp -RT ./node_modules/bootstrap/dist ./dist/public/bootstrap",

+ 2 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "6.1.5-RC.0",
+  "version": "6.1.7-RC.0",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -84,6 +84,7 @@
     "reg-notify-slack-plugin": "^0.11.0",
     "reg-publish-s3-plugin": "^0.11.0",
     "reg-suit": "^0.12.1",
+    "shx": "^0.3.4",
     "stylelint": "^14.2.0",
     "stylelint-config-recess-order": "^3.0.0",
     "ts-node-dev": "^2.0.0",

+ 2 - 2
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/core",
-  "version": "6.1.5-RC.0",
+  "version": "6.1.7-RC.0",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "keywords": [
@@ -14,7 +14,7 @@
   ],
   "scripts": {
     "build": "vite build",
-    "clean": "npx -y shx rm -rf dist",
+    "clean": "shx rm -rf dist",
     "dev": "vite build --mode dev",
     "watch": "yarn dev -w --emptyOutDir=false",
     "lint:js": "yarn eslint **/*.{js,ts}",

+ 1 - 1
packages/core/src/utils/page-path-utils/index.ts

@@ -113,7 +113,7 @@ const restrictedPatternsToCreate: Array<RegExp> = [
   /(\/\.\.)\/?/, // see: https://github.com/weseek/growi/issues/3582
   /\\/, // see: https://github.com/weseek/growi/issues/7241
   /^\/(_search|_private-legacy-pages)(\/.*|$)/,
-  /^\/(installer|register|login|logout|admin|me|files|trash|paste|comments|tags|share)(\/.*|$)/,
+  /^\/(installer|register|login|logout|admin|me|files|trash|paste|comments|tags|share|attachment)(\/.*|$)/,
   /^\/user\/[^/]+$/, // see: https://regex101.com/r/utVQct/1
 ];
 export const isCreatablePage = (path: string): boolean => {

+ 2 - 2
packages/hackmd/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/hackmd",
-  "version": "6.1.5-RC.0",
+  "version": "6.1.7-RC.0",
   "description": "GROWI js and css files to use hackmd",
   "license": "MIT",
   "type": "module",
@@ -9,7 +9,7 @@
   "types": "dist/index.d.ts",
   "scripts": {
     "build": "vite build",
-    "clean": "npx -y shx rm -rf dist",
+    "clean": "shx rm -rf dist",
     "dev": "vite build --mode dev",
     "watch": "yarn dev -w --emptyOutDir=false",
     "lint:js": "yarn eslint **/*.{js,ts}",

+ 1 - 1
packages/pluginkit/package.json

@@ -7,7 +7,7 @@
   "types": "dist/index.d.ts",
   "scripts": {
     "build": "vite build",
-    "clean": "npx -y shx rm -rf dist",
+    "clean": "shx rm -rf dist",
     "dev": "vite build --mode dev",
     "watch": "yarn dev -w --emptyOutDir=false",
     "lint:js": "yarn eslint **/*.{js,ts}",

+ 2 - 2
packages/presentation/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/presentation",
-  "version": "6.1.5-RC.0",
+  "version": "6.1.7-RC.0",
   "description": "GROWI plugin for presentation",
   "license": "MIT",
   "keywords": [
@@ -14,7 +14,7 @@
   ],
   "scripts": {
     "build": "vite build",
-    "clean": "npx -y shx rm -rf dist",
+    "clean": "shx rm -rf dist",
     "dev": "vite build --mode dev",
     "watch": "yarn dev -w --emptyOutDir=false",
     "lint:js": "yarn eslint **/*.{js,jsx,ts,tsx}",

+ 1 - 1
packages/preset-templates/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/preset-templates",
-  "version": "6.1.5-RC.0",
+  "version": "6.1.7-RC.0",
   "scripts": {
     "test": "vitest run",
     "version": "yarn version --no-git-tag-version --preid=RC"

+ 1 - 1
packages/preset-themes/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@growi/preset-themes",
   "description": "GROWI preset themes",
-  "version": "6.1.5-RC.0",
+  "version": "6.1.7-RC.0",
   "license": "MIT",
   "main": "dist/libs/preset-themes.umd.js",
   "module": "dist/libs/preset-themes.mjs",

+ 2 - 2
packages/remark-attachment-refs/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/remark-attachment-refs",
-  "version": "6.1.5-RC.0",
+  "version": "6.1.7-RC.0",
   "description": "GROWI Plugin to add ref/refimg/refs/refsimg tags",
   "license": "MIT",
   "keywords": [
@@ -17,7 +17,7 @@
     "build": "run-p build:*",
     "build:server": "vite build -c vite.server.config.ts",
     "build:client": "vite build -c vite.client.config.ts",
-    "clean": "npx -y shx rm -rf dist",
+    "clean": "shx rm -rf dist",
     "dev": "run-p dev:*",
     "dev:server": "vite build -c vite.server.config.ts --mode dev",
     "dev:client": "vite build -c vite.client.config.ts --mode dev",

+ 2 - 2
packages/remark-drawio/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/remark-drawio",
-  "version": "6.1.5-RC.0",
+  "version": "6.1.7-RC.0",
   "description": "remark plugin to draw diagrams with draw.io (diagrams.net)",
   "license": "MIT",
   "keywords": [
@@ -21,7 +21,7 @@
   "typings": "dist/index.d.ts",
   "scripts": {
     "build": "vite build",
-    "clean": "npx -y shx rm -rf dist",
+    "clean": "shx rm -rf dist",
     "dev": "vite build --mode dev",
     "watch": "yarn dev -w --emptyOutDir=false",
     "lint:js": "yarn eslint **/*.{js,jsx,ts,tsx}",

+ 2 - 2
packages/remark-growi-directive/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/remark-growi-directive",
-  "version": "6.1.5-RC.0",
+  "version": "6.1.7-RC.0",
   "description": "remark plugin to support GROWI plugin (forked from remark-directive@2.0.1)",
   "license": "MIT",
   "keywords": [
@@ -17,7 +17,7 @@
   "typings": "dist/index.d.ts",
   "scripts": {
     "build": "yarn tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",
-    "clean": "npx -y shx rm -rf dist",
+    "clean": "shx rm -rf dist",
     "dev": "yarn build",
     "watch": "yarn tsc -w",
     "test": "cross-env NODE_ENV=test npm run test-coverage",

+ 2 - 2
packages/remark-lsx/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/remark-lsx",
-  "version": "6.1.5-RC.0",
+  "version": "6.1.7-RC.0",
   "description": "GROWI plugin to list pages",
   "license": "MIT",
   "keywords": [
@@ -14,7 +14,7 @@
     "build": "run-p build:*",
     "build:client": "vite build -c vite.client.config.ts",
     "build:server": "vite build -c vite.server.config.ts",
-    "clean": "npx -y shx rm -rf dist",
+    "clean": "shx rm -rf dist",
     "dev": "run-p dev:*",
     "dev:client": "vite build -c vite.client.config.ts --mode dev",
     "dev:server": "vite build -c vite.server.config.ts --mode dev",

+ 2 - 2
packages/slack/package.json

@@ -1,13 +1,13 @@
 {
   "name": "@growi/slack",
-  "version": "6.1.5-RC.0",
+  "version": "6.1.7-RC.0",
   "license": "MIT",
   "main": "dist/index.js",
   "module": "dist/index.mjs",
   "types": "dist/index.d.ts",
   "scripts": {
     "build": "vite build",
-    "clean": "npx -y shx rm -rf dist",
+    "clean": "shx rm -rf dist",
     "dev": "vite build --mode dev",
     "watch": "yarn dev -w --emptyOutDir=false",
     "lint:js": "yarn eslint **/*.{js,ts}",

+ 2 - 2
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/ui",
-  "version": "6.1.5-RC.0",
+  "version": "6.1.7-RC.0",
   "description": "GROWI UI Libraries",
   "license": "MIT",
   "keywords": [
@@ -12,7 +12,7 @@
   ],
   "scripts": {
     "build": "vite build",
-    "clean": "npx -y shx rm -rf dist",
+    "clean": "shx rm -rf dist",
     "dev": "vite build --mode dev",
     "watch": "yarn dev -w --emptyOutDir=false",
     "lint:js": "yarn eslint **/*.{js,ts}",

+ 62 - 12
yarn.lock

@@ -2312,13 +2312,13 @@
     xdg-basedir "^4.0.0"
 
 "@growi/core@link:packages/core":
-  version "6.1.5-RC.0"
+  version "6.1.7-RC.0"
   dependencies:
     bson-objectid "^2.0.4"
     escape-string-regexp "^4.0.0"
 
 "@growi/hackmd@link:packages/hackmd":
-  version "6.1.5-RC.0"
+  version "6.1.7-RC.0"
 
 "@growi/pluginkit@link:packages/pluginkit":
   version "0.1.0"
@@ -2327,18 +2327,18 @@
     extensible-custom-error "^0.0.7"
 
 "@growi/presentation@link:packages/presentation":
-  version "6.1.5-RC.0"
+  version "6.1.7-RC.0"
   dependencies:
     "@growi/core" "link:packages/core"
 
 "@growi/preset-templates@link:packages/preset-templates":
-  version "6.1.5-RC.0"
+  version "6.1.7-RC.0"
 
 "@growi/preset-themes@link:packages/preset-themes":
-  version "6.1.5-RC.0"
+  version "6.1.7-RC.0"
 
 "@growi/remark-attachment-refs@link:packages/remark-attachment-refs":
-  version "6.1.5-RC.0"
+  version "6.1.7-RC.0"
   dependencies:
     "@growi/core" "link:packages/core"
     "@growi/remark-growi-directive" "link:packages/remark-growi-directive"
@@ -2347,12 +2347,12 @@
     universal-bunyan "^0.9.2"
 
 "@growi/remark-drawio@link:packages/remark-drawio":
-  version "6.1.5-RC.0"
+  version "6.1.7-RC.0"
   dependencies:
     pako "^2.1.0"
 
 "@growi/remark-growi-directive@link:packages/remark-growi-directive":
-  version "6.1.5-RC.0"
+  version "6.1.7-RC.0"
   dependencies:
     "@types/mdast" "^3.0.0"
     "@types/unist" "^2.0.0"
@@ -2369,7 +2369,7 @@
     uvu "^0.5.0"
 
 "@growi/remark-lsx@link:packages/remark-lsx":
-  version "6.1.5-RC.0"
+  version "6.1.7-RC.0"
   dependencies:
     "@growi/core" "link:packages/core"
     "@growi/remark-growi-directive" "link:packages/remark-growi-directive"
@@ -2380,7 +2380,7 @@
     swr "^2.0.3"
 
 "@growi/slack@link:packages/slack":
-  version "6.1.5-RC.0"
+  version "6.1.7-RC.0"
   dependencies:
     "@slack/oauth" "^2.0.1"
     axios "^0.24.0"
@@ -2393,7 +2393,7 @@
     url-join "^4.0.0"
 
 "@growi/ui@link:packages/ui":
-  version "6.1.5-RC.0"
+  version "6.1.7-RC.0"
   dependencies:
     "@growi/core" "link:packages/core"
 
@@ -8835,7 +8835,7 @@ glob@^6.0.1:
     once "^1.3.0"
     path-is-absolute "^1.0.0"
 
-glob@^7.0.5, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@^7.2.0:
+glob@^7.0.0, glob@^7.0.5, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@^7.2.0:
   version "7.2.3"
   resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b"
   integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==
@@ -9703,6 +9703,11 @@ internal-slot@^1.0.3:
   resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009"
   integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==
 
+interpret@^1.0.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e"
+  integrity sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==
+
 invariant@^2.2.1, invariant@^2.2.4:
   version "2.2.4"
   resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
@@ -9838,6 +9843,13 @@ is-core-module@^2.1.0, is-core-module@^2.2.0, is-core-module@^2.8.1, is-core-mod
   dependencies:
     has "^1.0.3"
 
+is-core-module@^2.11.0:
+  version "2.12.1"
+  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.12.1.tgz#0c0b6885b6f80011c71541ce15c8d66cf5a4f9fd"
+  integrity sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg==
+  dependencies:
+    has "^1.0.3"
+
 is-data-descriptor@^0.1.4:
   version "0.1.4"
   resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56"
@@ -12306,6 +12318,11 @@ minimist@1, minimist@^1.1.0, minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.5,
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18"
   integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==
 
+minimist@^1.2.3:
+  version "1.2.8"
+  resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
+  integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
+
 minipass@^3.0.0:
   version "3.1.3"
   resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.1.3.tgz#7d42ff1f39635482e15f9cdb53184deebd5815fd"
@@ -14438,6 +14455,13 @@ readdirp@~3.6.0:
   dependencies:
     picomatch "^2.2.1"
 
+rechoir@^0.6.2:
+  version "0.6.2"
+  resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384"
+  integrity sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==
+  dependencies:
+    resolve "^1.1.6"
+
 reconnecting-websocket@^4.4.0:
   version "4.4.0"
   resolved "https://registry.yarnpkg.com/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz#3b0e5b96ef119e78a03135865b8bb0af1b948783"
@@ -14966,6 +14990,15 @@ resolve@^1.0.0, resolve@^1.1.7, resolve@^1.10.0, resolve@^1.20.0, resolve@^1.22.
     path-parse "^1.0.7"
     supports-preserve-symlinks-flag "^1.0.0"
 
+resolve@^1.1.6:
+  version "1.22.2"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.2.tgz#0ed0943d4e301867955766c9f3e1ae6d01c6845f"
+  integrity sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==
+  dependencies:
+    is-core-module "^2.11.0"
+    path-parse "^1.0.7"
+    supports-preserve-symlinks-flag "^1.0.0"
+
 resolve@^2.0.0-next.3:
   version "2.0.0-next.3"
   resolved "https://registry.yarnpkg.com/resolve/-/resolve-2.0.0-next.3.tgz#d41016293d4a8586a39ca5d9b5f15cbea1f55e46"
@@ -15329,6 +15362,15 @@ shell-quote@^1.6.1:
   resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.7.2.tgz#67a7d02c76c9da24f99d20808fcaded0e0e04be2"
   integrity sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg==
 
+shelljs@^0.8.5:
+  version "0.8.5"
+  resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.5.tgz#de055408d8361bed66c669d2f000538ced8ee20c"
+  integrity sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==
+  dependencies:
+    glob "^7.0.0"
+    interpret "^1.0.0"
+    rechoir "^0.6.2"
+
 should-equal@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/should-equal/-/should-equal-2.0.0.tgz#6072cf83047360867e68e98b09d71143d04ee0c3"
@@ -15373,6 +15415,14 @@ should@^13.2.1:
     should-type-adaptors "^1.0.1"
     should-util "^1.0.0"
 
+shx@^0.3.4:
+  version "0.3.4"
+  resolved "https://registry.yarnpkg.com/shx/-/shx-0.3.4.tgz#74289230b4b663979167f94e1935901406e40f02"
+  integrity sha512-N6A9MLVqjxZYcVn8hLmtneQWIJtp8IKzMP4eMnx+nqkvXoqinUPCbUFLp2UcWTEIUONhlk0ewxr/jaVGlc+J+g==
+  dependencies:
+    minimist "^1.2.3"
+    shelljs "^0.8.5"
+
 side-channel@^1.0.3, side-channel@^1.0.4:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf"