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

Merge branch 'master' into imprv/get-layout-pattern

Haku Mizuki 3 лет назад
Родитель
Сommit
704a194dda
39 измененных файлов с 412 добавлено и 322 удалено
  1. 1 0
      package.json
  2. 1 1
      packages/app/cypress.config.ts
  3. 3 3
      packages/app/public/static/locales/en_US/admin.json
  4. 3 3
      packages/app/public/static/locales/ja_JP/admin.json
  5. 3 3
      packages/app/public/static/locales/zh_CN/admin.json
  6. 8 12
      packages/app/src/client/services/AdminCustomizeContainer.js
  7. 13 7
      packages/app/src/client/services/AdminMarkDownContainer.js
  8. 8 1
      packages/app/src/client/services/page-operation.ts
  9. 8 8
      packages/app/src/components/Admin/Customize/Customize.jsx
  10. 3 0
      packages/app/src/components/Admin/Customize/CustomizeCssSetting.tsx
  11. 33 21
      packages/app/src/components/Admin/Customize/CustomizeNoscriptSetting.tsx
  12. 27 44
      packages/app/src/components/Admin/Customize/CustomizeScriptSetting.tsx
  13. 6 3
      packages/app/src/components/Admin/MarkdownSetting/XssForm.jsx
  14. 1 1
      packages/app/src/components/Layout/BasicLayout.tsx
  15. 1 1
      packages/app/src/components/LoginForm.tsx
  16. 7 3
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  17. 1 1
      packages/app/src/components/Navbar/PersonalDropdown.jsx
  18. 9 3
      packages/app/src/components/PageAlert/PageRedirectedAlert.tsx
  19. 10 5
      packages/app/src/components/PageAlert/TrashPageAlert.tsx
  20. 1 1
      packages/app/src/components/PageEditor/Cheatsheet.tsx
  21. 1 1
      packages/app/src/components/PageEditor/Editor.tsx
  22. 7 2
      packages/app/src/components/PageEditorByHackmd.tsx
  23. 19 5
      packages/app/src/components/PageList/PageListItemL.tsx
  24. 1 1
      packages/app/src/components/User/UserInfo.tsx
  25. 3 3
      packages/app/src/interfaces/activity.ts
  26. 1 1
      packages/app/src/pages/[[...path]].page.tsx
  27. 34 10
      packages/app/src/pages/_document.page.tsx
  28. 4 2
      packages/app/src/server/models/config.ts
  29. 22 22
      packages/app/src/server/routes/apiv3/customize-setting.js
  30. 16 7
      packages/app/src/server/routes/apiv3/markdown-setting.js
  31. 8 5
      packages/app/src/server/service/customize.ts
  32. 3 25
      packages/app/src/stores/page-redirect.tsx
  33. 8 11
      packages/app/test/cypress/integration/20-basic-features/20-basic-features--access-to-page.spec.ts
  34. 11 8
      packages/app/test/cypress/integration/20-basic-features/20-basic-features--access-to-pagelist.spec.ts
  35. 67 51
      packages/app/test/cypress/integration/20-basic-features/20-basic-features--use-tools.spec.ts
  36. 7 12
      packages/app/test/cypress/integration/21-basic-features-for-guest/21-basic-features-for-guest--access-to-page.spec.ts
  37. 14 12
      packages/app/test/cypress/integration/60-home/60-home--home.spec.ts
  38. 34 23
      packages/app/test/cypress/support/commands.ts
  39. 5 0
      yarn.lock

+ 1 - 0
package.json

@@ -62,6 +62,7 @@
     "@typescript-eslint/eslint-plugin": "^5.0.0",
     "@typescript-eslint/parser": "^5.0.0",
     "cypress": "^12.0.1",
+    "cypress-wait-until": "^1.7.2",
     "eslint": "^8.18.0",
     "eslint-config-next": "^12.1.6",
     "eslint-config-weseek": "^2.1.0",

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

@@ -16,6 +16,7 @@ export default defineConfig({
         return launchOptions;
       });
     },
+    defaultCommandTimeout: 10000,
   },
   fileServerFolder: 'test/cypress',
   fixturesFolder: 'test/cypress/fixtures',
@@ -25,5 +26,4 @@ export default defineConfig({
   viewportWidth: 1400,
   viewportHeight: 1024,
 
-  defaultCommandTimeout: 30000,
 });

+ 3 - 3
packages/app/public/static/locales/en_US/admin.json

@@ -498,8 +498,8 @@
     "custom_title_detail_placeholder1": "<code>&#123;&#123;sitename&#125;&#125;</code> - The site name of this wiki.",
     "custom_title_detail_placeholder2": "<code>&#123;&#123;pagename&#125;&#125;</code> - The page name of the current page.",
     "custom_title_detail_placeholder3": "<code>&#123;&#123;pagepath&#125;&#125;</code> - The page path of the current page.",
-    "custom_header": "Custom HTML header",
-    "custom_header_detail": "You can customize HTML header that applies all pages. Your custom script will be inserted in <code>&lt;header&gt;</code> but above other <code>&lt;script&gt;</code> tags.<br>Relaod page to see changes.",
+    "custom_noscript": "Custom Noscript",
+    "custom_noscript_detail": "You can customize Noscript code that applies all pages. Your custom Noscript will be inserted into the <code>&lt;noscript&gt;</code> tag that is located as the first element of body.<br>Relaod page to see changes.",
     "custom_css": "Custom CSS",
     "write_css": "You can write CSS that is applied to whole system.",
     "ctrl_space": "Ctrl+Space to autocomplete",
@@ -980,7 +980,7 @@
     "ADMIN_FUNCTION_UPDATE": "Update Function",
     "ADMIN_CODE_HIGHLIGHT_UPDATE": "Update Code Highlight",
     "ADMIN_CUSTOM_TITLE_UPDATE": "Update Custom Title",
-    "ADMIN_CUSTOM_HTML_HEADER_UPDATE": "Update Custom HTML header",
+    "ADMIN_CUSTOM_NOSCRIPT_UPDATE": "Update Custom noscript",
     "ADMIN_CUSTOM_CSS_UPDATE": "Update Custom CSS",
     "ADMIN_CUSTOM_SCRIPT_UPDATE": "Update Custom script",
     "ADMIN_ARCHIVE_DATA_UPLOAD": "Upload Archived Data",

+ 3 - 3
packages/app/public/static/locales/ja_JP/admin.json

@@ -506,8 +506,8 @@
     "custom_title_detail_placeholder1": "<code>&#123;&#123;sitename&#125;&#125;</code> - この Wiki のサイト名",
     "custom_title_detail_placeholder2": "<code>&#123;&#123;pagename&#125;&#125;</code> - 現在表示中のページ名",
     "custom_title_detail_placeholder3": "<code>&#123;&#123;pagepath&#125;&#125;</code> - 現在表示中のページパス",
-    "custom_header": "カスタム HTML Header",
-    "custom_header_detail": "システム全体に適用される HTML を記述できます。<code>&lt;header&gt;</code> タグ内の他の <code>&lt;script&gt;</code> タグ読み込み前に展開されます。<br>変更の反映はページの更新が必要です。",
+    "custom_noscript": "カスタム Noscript",
+    "custom_noscript_detail": "システム全体に適用される HTML を記述できます。<code>&lt;body&gt;</code> タグ内の最初の <code>&lt;noscript&gt;</code> タグ内に展開されます。<br>変更の反映はページの更新が必要です。",
     "custom_css": "カスタム CSS",
     "write_css": " システム全体に適用されるCSSを記述できます。",
     "ctrl_space": "Ctrl+Space でコード補完",
@@ -988,7 +988,7 @@
     "ADMIN_FUNCTION_UPDATE": "機能設定の更新",
     "ADMIN_CODE_HIGHLIGHT_UPDATE": "コードハイライト設定の更新",
     "ADMIN_CUSTOM_TITLE_UPDATE": "カスタムタイトル設定の更新",
-    "ADMIN_CUSTOM_HTML_HEADER_UPDATE": "カスタム HTML Header 設定の更新",
+    "ADMIN_CUSTOM_NOSCRIPT_UPDATE": "カスタム noscript 設定の更新",
     "ADMIN_CUSTOM_CSS_UPDATE": "カスタム CSS 設定の更新",
     "ADMIN_CUSTOM_SCRIPT_UPDATE": "カスタムスクリプト設定の更新",
     "ADMIN_ARCHIVE_DATA_UPLOAD": "アーカイブデータのアップロード",

+ 3 - 3
packages/app/public/static/locales/zh_CN/admin.json

@@ -506,8 +506,8 @@
     "custom_title_detail_placeholder1": "<code>&#123;&#123;站点名称&#125;&#125;</code>-此wiki的站点名称。",
     "custom_title_detail_placeholder2": "<code>&#123;&#123;页名&#125;&#125;</code>-当前页的页名。",
     "custom_title_detail_placeholder3": "<code>&#123;&#123;页面路径&#125;&#125;</code>-当前页面的页面路径。",
-    "custom_header": "自定义HTML标题",
-    "custom_header_detail": "您可以自定义应用所有页面的HTML标题。您的自定义脚本将插入<code>&lt;header&gt;</code>中,但位于其他<code>&lt;script&gt;</code>标记之上。<br>重新链接页面以查看更改。",
+    "custom_noscript": "自定义 Noscript 标题",
+    "custom_noscript_detail": "您可以自定义应用所有页面的 Noscript 代码。 您的自定义 Noscript 将被插入到作为 body 的第一个元素的 <code>&lt;noscript&gt;</code> 标签中。<br>重新链接页面以查看更改。",
     "custom_css": "自定义CSS",
     "write_css": "您可以编写应用于整个系统的CSS。",
     "ctrl_space": "Ctrl+Space 自动完成",
@@ -988,7 +988,7 @@
     "ADMIN_FUNCTION_UPDATE": "更新函数",
     "ADMIN_CODE_HIGHLIGHT_UPDATE": "更新代码高亮",
     "ADMIN_CUSTOM_TITLE_UPDATE": "更新自定义标题",
-    "ADMIN_CUSTOM_HTML_HEADER_UPDATE": "更新自定义 HTML 标头",
+    "ADMIN_CUSTOM_NOSCRIPT_UPDATE": "更新自定义 noscript 标头",
     "ADMIN_CUSTOM_CSS_UPDATE": "更新自定义 CSS",
     "ADMIN_CUSTOM_SCRIPT_UPDATE": "更新自定义脚本",
     "ADMIN_ARCHIVE_DATA_UPLOAD": "上传存档数据",

+ 8 - 12
packages/app/src/client/services/AdminCustomizeContainer.js

@@ -36,7 +36,7 @@ export default class AdminCustomizeContainer extends Container {
       isAllReplyShown: false,
       isSearchScopeChildrenAsDefault: false,
       currentCustomizeTitle: '',
-      currentCustomizeHeader: '',
+      currentCustomizeNoscript: '',
       currentCustomizeCss: '',
       currentCustomizeScript: '',
     };
@@ -73,7 +73,7 @@ export default class AdminCustomizeContainer extends Container {
         isAllReplyShown: customizeParams.isAllReplyShown,
         isSearchScopeChildrenAsDefault: customizeParams.isSearchScopeChildrenAsDefault,
         currentCustomizeTitle: customizeParams.customizeTitle,
-        currentCustomizeHeader: customizeParams.customizeHeader,
+        currentCustomizeNoscript: customizeParams.customizeNoscript,
         currentCustomizeCss: customizeParams.customizeCss,
         currentCustomizeScript: customizeParams.customizeScript,
       });
@@ -160,8 +160,8 @@ export default class AdminCustomizeContainer extends Container {
   /**
    * Change customize Html header
    */
-  changeCustomizeHeader(inputValue) {
-    this.setState({ currentCustomizeHeader: inputValue });
+  changeCustomizeNoscript(inputValue) {
+    this.setState({ currentCustomizeNoscript: inputValue });
   }
 
   /**
@@ -235,18 +235,14 @@ export default class AdminCustomizeContainer extends Container {
     }
   }
 
-  /**
-   * Update customHeader
-   * @memberOf AdminCustomizeContainer
-   */
-  async updateCustomizeHeader() {
+  async updateCustomizeNoscript() {
     try {
-      const response = await apiv3Put('/customize-setting/customize-header', {
-        customizeHeader: this.state.currentCustomizeHeader,
+      const response = await apiv3Put('/customize-setting/customize-noscript', {
+        customizeNoscript: this.state.currentCustomizeNoscript,
       });
       const { customizedParams } = response.data;
       this.setState({
-        currentCustomizeHeader: customizedParams.customizeHeader,
+        currentCustomizeNoscript: customizedParams.customizeNoscript,
       });
     }
     catch (err) {

+ 13 - 7
packages/app/src/client/services/AdminMarkDownContainer.js

@@ -29,7 +29,7 @@ export default class AdminMarkDownContainer extends Container {
       isEnabledXss: false,
       xssOption: '',
       tagWhiteList: '',
-      attrWhiteList: '',
+      attrWhiteList: '{}',
     };
 
     this.switchEnableXss = this.switchEnableXss.bind(this);
@@ -119,19 +119,25 @@ export default class AdminMarkDownContainer extends Container {
    * Update Xss Setting
    */
   async updateXssSetting() {
-    let { tagWhiteList, attrWhiteList } = this.state;
+    let { tagWhiteList } = this.state;
+    const { attrWhiteList } = this.state;
 
     tagWhiteList = Array.isArray(tagWhiteList) ? tagWhiteList : tagWhiteList.split(',');
-    attrWhiteList = Array.isArray(attrWhiteList) ? attrWhiteList : attrWhiteList.split(',');
 
-    const response = await apiv3Put('/markdown-setting/xss', {
+    try {
+      // Check if parsing is possible
+      JSON.parse(attrWhiteList);
+    }
+    catch (err) {
+      throw Error(err);
+    }
+
+    await apiv3Put('/markdown-setting/xss', {
       isEnabledXss: this.state.isEnabledXss,
       xssOption: this.state.xssOption,
       tagWhiteList,
-      attrWhiteList,
+      attrWhiteList: attrWhiteList ?? '{}',
     });
-
-    return response;
   }
 
   /**

+ 8 - 1
packages/app/src/client/services/page-operation.ts

@@ -3,7 +3,7 @@ import urljoin from 'url-join';
 
 import { OptionsToSave } from '~/interfaces/page-operation';
 import { useCurrentPageId } from '~/stores/context';
-import { useIsEnabledUnsavedWarning, usePageTagsForEditors } from '~/stores/editor';
+import { useEditingMarkdown, useIsEnabledUnsavedWarning, usePageTagsForEditors } from '~/stores/editor';
 import { useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
 import { useSetRemoteLatestPageData } from '~/stores/remote-latest-page';
 import loggerFactory from '~/utils/logger';
@@ -181,6 +181,7 @@ export const useUpdateStateAfterSave = (pageId: string|undefined|null): (() => P
   const { setRemoteLatestPageData } = useSetRemoteLatestPageData();
   const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
   const { sync: syncTagsInfoForEditor } = usePageTagsForEditors(pageId);
+  const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
 
   if (pageId == null) { return }
 
@@ -194,6 +195,8 @@ export const useUpdateStateAfterSave = (pageId: string|undefined|null): (() => P
 
     if (updatedPage == null) { return }
 
+    mutateEditingMarkdown(updatedPage.revision.body);
+
     const remoterevisionData = {
       remoteRevisionId: updatedPage.revision._id,
       remoteRevisionBody: updatedPage.revision.body,
@@ -206,3 +209,7 @@ export const useUpdateStateAfterSave = (pageId: string|undefined|null): (() => P
     setRemoteLatestPageData(remoterevisionData);
   };
 };
+
+export const unlink = async(path: string): Promise<void> => {
+  await apiPost('/pages.unlink', { path });
+};

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

@@ -12,9 +12,9 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 
 import CustomizeCssSetting from './CustomizeCssSetting';
 import CustomizeFunctionSetting from './CustomizeFunctionSetting';
-import CustomizeHeaderSetting from './CustomizeHeaderSetting';
 import CustomizeLayoutSetting from './CustomizeLayoutSetting';
 import CustomizeLogoSetting from './CustomizeLogoSetting';
+import CustomizeNoscriptSetting from './CustomizeNoscriptSetting';
 import CustomizeScriptSetting from './CustomizeScriptSetting';
 import CustomizeSidebarSetting from './CustomizeSidebarSetting';
 import CustomizeThemeSetting from './CustomizeThemeSetting';
@@ -44,10 +44,13 @@ function Customize(props) {
   return (
     <div data-testid="admin-customize">
       <div className="mb-5">
-        <CustomizeLayoutSetting />
+        <CustomizeThemeSetting />
       </div>
       <div className="mb-5">
-        <CustomizeThemeSetting />
+        <CustomizeLogoSetting />
+      </div>
+      <div className="mb-5">
+        <CustomizeLayoutSetting />
       </div>
       <div className="mb-5">
         <CustomizeSidebarSetting />
@@ -59,16 +62,13 @@ function Customize(props) {
         <CustomizeTitle />
       </div>
       <div className="mb-5">
-        <CustomizeHeaderSetting />
+        <CustomizeScriptSetting />
       </div>
       <div className="mb-5">
         <CustomizeCssSetting />
       </div>
       <div className="mb-5">
-        <CustomizeScriptSetting />
-      </div>
-      <div className="mb-5">
-        <CustomizeLogoSetting />
+        <CustomizeNoscriptSetting />
       </div>
     </div>
   );

+ 3 - 0
packages/app/src/components/Admin/Customize/CustomizeCssSetting.tsx

@@ -45,13 +45,16 @@ const CustomizeCssSetting = (props: Props): JSX.Element => {
             <textarea
               className="form-control"
               name="customizeCss"
+              rows={8}
               value={adminCustomizeContainer.state.currentCustomizeCss || ''}
               onChange={(e) => { adminCustomizeContainer.changeCustomizeCss(e.target.value) }}
             />
+            {/* disabled in v6.0.0 temporarily -- 2022.12.19 Yuki Takei
             <p className="form-text text-muted text-right">
               <i className="fa fa-fw fa-keyboard-o" aria-hidden="true" />
               {t('admin:customize_settings.ctrl_space')}
             </p>
+            */}
           </div>
 
           <AdminUpdateButtonRow onClick={onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />

+ 33 - 21
packages/app/src/components/Admin/Customize/CustomizeHeaderSetting.tsx → packages/app/src/components/Admin/Customize/CustomizeNoscriptSetting.tsx

@@ -1,6 +1,8 @@
 import React, { useCallback } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import { PrismAsyncLight } from 'react-syntax-highlighter';
+import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
 import { Card, CardBody } from 'reactstrap';
 
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
@@ -13,15 +15,15 @@ type Props = {
   adminCustomizeContainer: AdminCustomizeContainer
 }
 
-const CustomizeHeaderSetting = (props: Props): JSX.Element => {
+const CustomizeNoscriptSetting = (props: Props): JSX.Element => {
 
   const { adminCustomizeContainer } = props;
   const { t } = useTranslation();
 
   const onClickSubmit = useCallback(async() => {
     try {
-      await adminCustomizeContainer.updateCustomizeHeader();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.custom_header'), ns: 'commons' }));
+      await adminCustomizeContainer.updateCustomizeNoscript();
+      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.custom_noscript'), ns: 'commons' }));
     }
     catch (err) {
       toastError(err);
@@ -32,38 +34,48 @@ const CustomizeHeaderSetting = (props: Props): JSX.Element => {
     <React.Fragment>
       <div className="row">
         <div className="col-12">
-          <h2 className="admin-setting-header">{t('admin:customize_settings.custom_header')}</h2>
+          <h2 className="admin-setting-header">{t('admin:customize_settings.custom_noscript')}</h2>
 
           <Card className="card well my-3">
             <CardBody className="px-0 py-2">
               <span
                 // eslint-disable-next-line react/no-danger
-                dangerouslySetInnerHTML={{ __html: t('admin:customize_settings.custom_header_detail') }}
+                dangerouslySetInnerHTML={{ __html: t('admin:customize_settings.custom_noscript_detail') }}
               />
             </CardBody>
           </Card>
-          <div className="form-text text-muted">
-            { t('Example') }:
-            <pre>
-              {/* eslint-disable-next-line react/no-unescaped-entities */}
-              <code className="text-wrap">&lt;script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.13.0/build/languages/yaml.min.js"
-                defer&gt;&lt;/script&gt;
-              </code>
-            </pre>
-          </div>
 
           <div className="form-group">
             <textarea
               className="form-control"
-              name="customizeHeader"
-              value={adminCustomizeContainer.state.currentCustomizeHeader || ''}
-              onChange={(e) => { adminCustomizeContainer.changeCustomizeHeader(e.target.value) }}
+              name="customizeNoscript"
+              rows={8}
+              value={adminCustomizeContainer.state.currentCustomizeNoscript || ''}
+              onChange={(e) => { adminCustomizeContainer.changeCustomizeNoscript(e.target.value) }}
             />
-            <p className="form-text text-muted text-right">
+            {/* disabled in v6.0.0 temporarily -- 2022.12.19 Yuki Takei
+            <span className="form-text text-muted text-right">
               <i className="fa fa-fw fa-keyboard-o" aria-hidden="true"></i>
               {t('admin:customize_settings.ctrl_space')}
-            </p>
+            </span>
+            */}
+          </div>
+
+          <a className="text-muted"
+            data-toggle="collapse" href="#collapseExampleHtml" role="button" aria-expanded="false" aria-controls="collapseExampleHtml">
+            <i className="fa fa-fw fa-chevron-right" aria-hidden="true"></i>
+            Example for Google Tag Manager
+          </a>
+          <div className="collapse" id="collapseExampleHtml">
+            <PrismAsyncLight style={oneDark} language={'javascript'}
+            >
+              {`<iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXXX"
+  height="0"
+  width="0"
+  style="display:none;visibility:hidden"></iframe>`}
+            </PrismAsyncLight>
           </div>
+
           <AdminUpdateButtonRow onClick={onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
         </div>
       </div>
@@ -72,6 +84,6 @@ const CustomizeHeaderSetting = (props: Props): JSX.Element => {
 
 };
 
-const CustomizeHeaderSettingWrapper = withUnstatedContainers(CustomizeHeaderSetting, [AdminCustomizeContainer]);
+const CustomizeNoscriptSettingWrapper = withUnstatedContainers(CustomizeNoscriptSetting, [AdminCustomizeContainer]);
 
-export default CustomizeHeaderSettingWrapper;
+export default CustomizeNoscriptSettingWrapper;

+ 27 - 44
packages/app/src/components/Admin/Customize/CustomizeScriptSetting.tsx

@@ -1,6 +1,8 @@
 import React, { useCallback } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import { PrismAsyncLight } from 'react-syntax-highlighter';
+import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
 import { Card, CardBody } from 'reactstrap';
 
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
@@ -28,14 +30,6 @@ const CustomizeScriptSetting = (props: Props): JSX.Element => {
     }
   }, [t, adminCustomizeContainer]);
 
-  const getExampleCode = useCallback(() => {
-    return `console.log($('.main-container'));
-    window.addEventListener('load', (event) => {
-      console.log('config: ', appContainer.config);
-    });
-    `;
-  }, []);
-
   return (
     <React.Fragment>
       <div className="row">
@@ -48,51 +42,40 @@ const CustomizeScriptSetting = (props: Props): JSX.Element => {
             </CardBody>
           </Card>
 
-          <div className="form-text text-muted">
-            Placeholders:<br />
-            (Available after <code>load</code> event)
-          </div>
-          <table className="table table-borderless table-sm form-text text-muted offset-1 col-11">
-            <tbody>
-              <tr>
-                <th className="text-right"><code>$</code></th>
-                <td>jQuery instance</td>
-              </tr>
-              <tr>
-                <th className="text-right"><code>appContainer</code></th>
-                <td>GROWI App <a href="https://github.com/jamiebuilds/unstated">unstated container</a></td>
-              </tr>
-              <tr>
-                <th className="text-right"><code>growiRenderer</code></th>
-                <td>GROWI Renderer origin instance</td>
-              </tr>
-              <tr>
-                <th className="text-right"><code>growiPlugin</code></th>
-                <td>GROWI Plugin Manager instance</td>
-              </tr>
-              <tr>
-                <th className="text-right"><code>Crowi</code></th>
-                <td>Crowi legacy instance (jQuery based)</td>
-              </tr>
-            </tbody>
-          </table>
-
-          <div className="form-text text-muted">
-            Examples:
-            <pre><code className='language-javascript'>{getExampleCode()}</code></pre>
-          </div>
-
           <div className="form-group">
             <textarea
               className="form-control"
               name="customizeScript"
+              rows={8}
               value={adminCustomizeContainer.state.currentCustomizeScript || ''}
               onChange={(e) => { adminCustomizeContainer.changeCustomizeScript(e.target.value) }}
             />
-            <p className="form-text text-muted text-right">
+            {/* disabled in v6.0.0 temporarily -- 2022.12.19 Yuki Takei
+            <span className="form-text text-muted text-right">
               <i className="fa fa-fw fa-keyboard-o" aria-hidden="true" />
               {t('admin:customize_settings.ctrl_space')}
-            </p>
+            </span>
+            */}
+          </div>
+
+          <a className="text-muted"
+            data-toggle="collapse" href="#collapseExampleScript" role="button" aria-expanded="false" aria-controls="collapseExampleScript">
+            <i className="fa fa-fw fa-chevron-right" aria-hidden="true"></i>
+            Example for Google Tag Manager
+          </a>
+          <div className="collapse" id="collapseExampleScript">
+            <PrismAsyncLight style={oneDark} language={'javascript'}
+            >
+              {`(function(w,d,s,l,i){
+w[l]=w[l]||[];
+w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});
+var f=d.getElementsByTagName(s)[0],
+  j=d.createElement(s),
+  dl=l!='dataLayer'?'&l='+l:'';
+j.async=true;
+j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
+})(window,document,'script','dataLayer','GTM-XXXXXX');`}
+            </PrismAsyncLight>
           </div>
 
           <AdminUpdateButtonRow onClick={onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />

+ 6 - 3
packages/app/src/components/Admin/MarkdownSetting/XssForm.jsx

@@ -2,11 +2,11 @@ import React from 'react';
 
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
+import { defaultSchema as sanitizeDefaultSchema } from 'rehype-sanitize';
 
 import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { RehypeSanitizeOption } from '~/interfaces/rehype';
-import { tags, attrs } from '~/services/xss/recommended-whitelist';
 import loggerFactory from '~/utils/logger';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
@@ -41,6 +41,9 @@ class XssForm extends React.Component {
     const { t, adminMarkDownContainer } = this.props;
     const { xssOption } = adminMarkDownContainer.state;
 
+    const rehypeRecommendedTags = sanitizeDefaultSchema.tagNames;
+    const rehypeRecommendedAttributes = JSON.stringify(sanitizeDefaultSchema.attributes);
+
     return (
       <div className="form-group col-12 my-3">
         <div className="row">
@@ -67,7 +70,7 @@ class XssForm extends React.Component {
                     rows="6"
                     cols="40"
                     readOnly
-                    defaultValue={tags}
+                    defaultValue={rehypeRecommendedTags}
                   />
                 </div>
                 <div className="mt-4">
@@ -80,7 +83,7 @@ class XssForm extends React.Component {
                     rows="6"
                     cols="40"
                     readOnly
-                    defaultValue={attrs}
+                    defaultValue={rehypeRecommendedAttributes}
                   />
                 </div>
               </label>

+ 1 - 1
packages/app/src/components/Layout/BasicLayout.tsx

@@ -39,7 +39,7 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
         <GrowiNavbar />
 
         <div className="page-wrapper d-flex d-print-block">
-          <div className="grw-sidebar-wrapper">
+          <div className="grw-sidebar-wrapper" data-testid="grw-sidebar-wrapper">
             <Sidebar />
           </div>
 

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

@@ -496,7 +496,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
   }
 
   return (
-    <div className="noLogin-dialog mx-auto" id="noLogin-dialog">
+    <div className="noLogin-dialog mx-auto" id="noLogin-dialog" data-testid="login-form">
       <div className="row mx-0">
         <div className="col-12">
           <ReactCardFlip isFlipped={isRegistering} flipDirection="horizontal" cardZIndex="3">

+ 7 - 3
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -191,13 +191,15 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const { data: shareLinkId } = useShareLinkId();
   const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
 
+  const { data: currentPathname } = useCurrentPathname();
+  const isSharedPage = pagePathUtils.isSharedPage(currentPathname ?? '');
+
   const revision = currentPage?.revision;
   const revisionId = (revision != null && isPopulated(revision)) ? revision._id : undefined;
 
   const { data: isDrawerMode } = useDrawerMode();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: pageId } = useCurrentPageId();
-  const { data: currentPathname } = useCurrentPathname();
   const { data: currentUser } = useCurrentUser();
   const { data: isNotFound } = useIsNotFound();
   const { data: isGuestUser } = useIsGuestUser();
@@ -209,8 +211,10 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const { data: isAbleToShowPageEditorModeManager } = useIsAbleToShowPageEditorModeManager();
   const { data: isAbleToShowPageAuthors } = useIsAbleToShowPageAuthors();
 
-  const { mutate: mutateSWRTagsInfo, data: tagsInfoData } = useSWRxTagsInfo(currentPage?._id);
-  const { data: tagsForEditors, mutate: mutatePageTagsForEditors, sync: syncPageTagsForEditors } = usePageTagsForEditors(currentPage?._id);
+  const { mutate: mutateSWRTagsInfo, data: tagsInfoData } = useSWRxTagsInfo(!isSharedPage ? currentPage?._id : undefined);
+
+  // eslint-disable-next-line max-len
+  const { data: tagsForEditors, mutate: mutatePageTagsForEditors, sync: syncPageTagsForEditors } = usePageTagsForEditors(!isSharedPage ? currentPage?._id : undefined);
 
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openRenameModal } = usePageRenameModal();

+ 1 - 1
packages/app/src/components/Navbar/PersonalDropdown.jsx

@@ -34,7 +34,7 @@ const PersonalDropdown = () => {
       {/* Button */}
       {/* remove .dropdown-toggle for hide caret */}
       {/* See https://stackoverflow.com/a/44577512/13183572 */}
-      <button className="bg-transparent border-0 nav-link" type="button" ref={buttonRef} data-toggle="dropdown">
+      <button className="bg-transparent border-0 nav-link" type="button" ref={buttonRef} data-toggle="dropdown" data-testid="personal-dropdown-button">
         <UserPicture user={user} noLink noTooltip /><span className="ml-1 d-none d-lg-inline-block">&nbsp;{user.name}</span>
       </button>
 

+ 9 - 3
packages/app/src/components/PageAlert/PageRedirectedAlert.tsx

@@ -2,24 +2,30 @@ import React, { useState, useCallback } from 'react';
 
 import { useTranslation } from 'next-i18next';
 
+import { unlink } from '~/client/services/page-operation';
 import { toastError } from '~/client/util/apiNotification';
+import { useCurrentPagePath } from '~/stores/page';
 import { useRedirectFrom } from '~/stores/page-redirect';
 
 export const PageRedirectedAlert = React.memo((): JSX.Element => {
   const { t } = useTranslation();
-  const { data: redirectFrom, unlink } = useRedirectFrom();
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { data: redirectFrom } = useRedirectFrom();
 
   const [isUnlinked, setIsUnlinked] = useState(false);
 
   const unlinkButtonClickHandler = useCallback(async() => {
+    if (currentPagePath == null) {
+      return;
+    }
     try {
-      await unlink();
+      await unlink(currentPagePath);
       setIsUnlinked(true);
     }
     catch (err) {
       toastError(err);
     }
-  }, [unlink]);
+  }, [currentPagePath]);
 
   if (redirectFrom == null || redirectFrom === '') {
     return <></>;

+ 10 - 5
packages/app/src/components/PageAlert/TrashPageAlert.tsx

@@ -5,10 +5,12 @@ import { format } from 'date-fns';
 import { useRouter } from 'next/router';
 import { useTranslation } from 'react-i18next';
 
+import { unlink } from '~/client/services/page-operation';
 import { toastError } from '~/client/util/apiNotification';
 import { usePageDeleteModal, usePutBackPageModal } from '~/stores/modal';
-import { useSWRxPageInfo, useSWRxCurrentPage, useIsTrashPage } from '~/stores/page';
-import { useRedirectFrom } from '~/stores/page-redirect';
+import {
+  useCurrentPagePath, useSWRxPageInfo, useSWRxCurrentPage, useIsTrashPage,
+} from '~/stores/page';
 import { useIsAbleToShowTrashPageManagementButtons } from '~/stores/ui';
 
 
@@ -34,7 +36,7 @@ export const TrashPageAlert = (): JSX.Element => {
 
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openPutBackPageModal } = usePutBackPageModal();
-  const { unlink } = useRedirectFrom();
+  const { data: currentPagePath } = useCurrentPagePath();
 
 
   const deleteUser = pageData?.deleteUser;
@@ -47,8 +49,11 @@ export const TrashPageAlert = (): JSX.Element => {
       return;
     }
     const putBackedHandler = () => {
+      if (currentPagePath == null) {
+        return;
+      }
       try {
-        unlink();
+        unlink(currentPagePath);
         // Do not use "router.push(`/${pageId}`)" to avoid `Error: Invariant: attempted to hard navigate to the same URL`
         // See: https://github.com/weseek/growi/pull/7054
         router.reload();
@@ -58,7 +63,7 @@ export const TrashPageAlert = (): JSX.Element => {
       }
     };
     openPutBackPageModal({ pageId, path: pagePath }, { onPutBacked: putBackedHandler });
-  }, [openPutBackPageModal, pageId, pagePath, router, unlink]);
+  }, [currentPagePath, openPutBackPageModal, pageId, pagePath, router]);
 
   const openPageDeleteModalHandler = useCallback(() => {
     if (pageId === undefined || revisionId === undefined || pagePath === undefined) {

+ 1 - 1
packages/app/src/components/PageEditor/Cheatsheet.tsx

@@ -17,7 +17,7 @@ export const Cheatsheet = (): JSX.Element => {
   const codeBlockStr = 'text\n\ntext';
   const lineBlockStr = 'text\ntext';
   const typographyStr = `*${t('sandbox.italics')}*\n**${t('sandbox.bold')}**\n***${t('sandbox.italic_bold')}***\n~~${t('sandbox.strikethrough')}~~`;
-  const linkStr = '[Google](https://www.google.co.jp/)\n[/Page1/ChildPage1]';
+  const linkStr = '[Google](https://www.google.co.jp/)';
   const codeHighlightStr = '```javascript:index.js\nwriteCode();\n```';
 
   // Right Side

+ 1 - 1
packages/app/src/components/PageEditor/Editor.tsx

@@ -246,7 +246,7 @@ const Editor: ForwardRefRenderFunction<IEditorMethods, EditorPropsType> = (props
     };
 
     return (
-      <Modal isOpen={isCheatsheetModalShown} toggle={hideCheatsheetModal} className={`modal-gfm-cheatsheet ${styles['modal-gfm-cheatsheet']}`} >
+      <Modal isOpen={isCheatsheetModalShown} toggle={hideCheatsheetModal} className={`modal-gfm-cheatsheet ${styles['modal-gfm-cheatsheet']}`} size={'lg'} >
         <ModalHeader tag="h4" toggle={hideCheatsheetModal} className="bg-primary text-light">
           <i className="icon-fw icon-question" />Markdown help
         </ModalHeader>

+ 7 - 2
packages/app/src/components/PageEditorByHackmd.tsx

@@ -259,6 +259,8 @@ export const PageEditorByHackmd = (): JSX.Element => {
       updateStateAfterSave?.();
       mutateTagsInfo();
 
+      mutateIsEnabledUnsavedWarning(false);
+
       logger.debug('success to save');
 
       toastSuccess(t('successfully_saved_the_page'));
@@ -267,7 +269,8 @@ export const PageEditorByHackmd = (): JSX.Element => {
       logger.error('failed to save', error);
       toastError(error.message);
     }
-  }, [currentPagePath, currentPathname, pageId, revisionIdHackmdSynced, optionsToSave, saveOrUpdate, mutatePageData, updateStateAfterSave, mutateTagsInfo, t]);
+  }, [currentPagePath, currentPathname, pageId, revisionIdHackmdSynced, optionsToSave,
+      saveOrUpdate, mutatePageData, updateStateAfterSave, mutateTagsInfo, mutateIsEnabledUnsavedWarning, t]);
 
   /**
    * onChange event of HackmdEditor handler
@@ -283,13 +286,15 @@ export const PageEditorByHackmd = (): JSX.Element => {
       return;
     }
 
+    mutateIsEnabledUnsavedWarning(true);
+
     try {
       await apiPost('/hackmd.saveOnHackmd', { pageId });
     }
     catch (err) {
       logger.error(err);
     }
-  }, [pageId, revision?.body, hackmdUri]);
+  }, [hackmdUri, pageId, revision?.body, mutateIsEnabledUnsavedWarning]);
 
   const penpalErrorOccuredHandler = useCallback((error) => {
     toastError(error.message);

+ 19 - 5
packages/app/src/components/PageList/PageListItemL.tsx

@@ -11,11 +11,10 @@ import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
 import Clamp from 'react-multiline-clamp';
 import { CustomInput } from 'reactstrap';
-import urljoin from 'url-join';
-
 
 import { ISelectable } from '~/client/interfaces/selectable-all';
-import { bookmark, unbookmark } from '~/client/services/page-operation';
+import { unlink, bookmark, unbookmark } from '~/client/services/page-operation';
+import { toastError } from '~/client/util/apiNotification';
 import {
   IPageInfoAll, isIPageInfoForListing, isIPageInfoForEntity, IPageWithMeta, IPageInfoForListing,
 } from '~/interfaces/page';
@@ -148,9 +147,24 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
     openDeleteModal([pageToDelete], { onDeleted: onPageDeleted });
   }, [pageData, openDeleteModal, onPageDeleted]);
 
-  const revertMenuItemClickHandler = useCallback(() => {
+  const revertMenuItemClickHandler = useCallback(async() => {
     const { _id: pageId, path } = pageData;
-    openPutBackPageModal({ pageId, path }, { onPutBacked: onPagePutBacked });
+
+    const putBackedHandler = async(path) => {
+      try {
+        // pageData path should be `/trash/fuga` (`/trash` should be included to the prefix)
+        await unlink(pageData.path);
+      }
+      catch (err) {
+        toastError(err);
+      }
+
+      if (onPagePutBacked != null) {
+        // This path should be `/fuga` ( `/trash` is not included to the prefix)
+        onPagePutBacked(path);
+      }
+    };
+    openPutBackPageModal({ pageId, path }, { onPutBacked: putBackedHandler });
   }, [onPagePutBacked, openPutBackPageModal, pageData]);
 
   const styleListGroupItem = (!isDeviceSmallerThanLg && onClickItem != null) ? 'list-group-item-action' : '';

+ 1 - 1
packages/app/src/components/User/UserInfo.tsx

@@ -19,7 +19,7 @@ export const UserInfo = (props: UserInfoProps): JSX.Element => {
   }
 
   return (
-    <div className={`${styles['grw-users-info']} grw-users-info d-flex align-items-center d-edit-none mb-5 pb-3 border-bottom`}>
+    <div className={`${styles['grw-users-info']} grw-users-info d-flex align-items-center d-edit-none mb-5 pb-3 border-bottom`} data-testid="grw-users-info">
       <UserPicture user={author} />
       <div className="users-meta">
         <h1 className="user-page-name">

+ 3 - 3
packages/app/src/interfaces/activity.ts

@@ -112,7 +112,7 @@ const ACTION_ADMIN_SIDEBAR_UPDATE = 'ADMIN_SIDEBAR_UPDATE';
 const ACTION_ADMIN_FUNCTION_UPDATE = 'ADMIN_FUNCTION_UPDATE';
 const ACTION_ADMIN_CODE_HIGHLIGHT_UPDATE = 'ADMIN_CODE_HIGHLIGHT_UPDATE';
 const ACTION_ADMIN_CUSTOM_TITLE_UPDATE = 'ADMIN_CUSTOM_TITLE_UPDATE';
-const ACTION_ADMIN_CUSTOM_HTML_HEADER_UPDATE = 'ADMIN_CUSTOM_HTML_HEADER_UPDATE';
+const ACTION_ADMIN_CUSTOM_NOSCRIPT_UPDATE = 'ADMIN_CUSTOM_NOSCRIPT_UPDATE';
 const ACTION_ADMIN_CUSTOM_CSS_UPDATE = 'ADMIN_CUSTOM_CSS_UPDATE';
 const ACTION_ADMIN_CUSTOM_SCRIPT_UPDATE = 'ADMIN_CUSTOM_SCRIPT_UPDATE';
 const ACTION_ADMIN_ARCHIVE_DATA_UPLOAD = 'ADMIN_ARCHIVE_DATA_UPLOAD';
@@ -291,7 +291,7 @@ export const SupportedAction = {
   ACTION_ADMIN_FUNCTION_UPDATE,
   ACTION_ADMIN_CODE_HIGHLIGHT_UPDATE,
   ACTION_ADMIN_CUSTOM_TITLE_UPDATE,
-  ACTION_ADMIN_CUSTOM_HTML_HEADER_UPDATE,
+  ACTION_ADMIN_CUSTOM_NOSCRIPT_UPDATE,
   ACTION_ADMIN_CUSTOM_CSS_UPDATE,
   ACTION_ADMIN_CUSTOM_SCRIPT_UPDATE,
   ACTION_ADMIN_ARCHIVE_DATA_UPLOAD,
@@ -478,7 +478,7 @@ export const LargeActionGroup = {
   ACTION_ADMIN_FUNCTION_UPDATE,
   ACTION_ADMIN_CODE_HIGHLIGHT_UPDATE,
   ACTION_ADMIN_CUSTOM_TITLE_UPDATE,
-  ACTION_ADMIN_CUSTOM_HTML_HEADER_UPDATE,
+  ACTION_ADMIN_CUSTOM_NOSCRIPT_UPDATE,
   ACTION_ADMIN_CUSTOM_CSS_UPDATE,
   ACTION_ADMIN_CUSTOM_SCRIPT_UPDATE,
   ACTION_ADMIN_ARCHIVE_DATA_UPLOAD,

+ 1 - 1
packages/app/src/pages/[[...path]].page.tsx

@@ -261,7 +261,7 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
 
   useCurrentPageId(pageId ?? null);
   useRevisionIdHackmdSynced(pageWithMeta?.data.revisionHackmdSynced);
-  useRemoteRevisionId(pageWithMeta?.data.revision._id);
+  useRemoteRevisionId(pageWithMeta?.data.revision?._id);
   usePageIdOnHackmd(pageWithMeta?.data.pageIdOnHackmd);
   useHasDraftOnHackmd(pageWithMeta?.data.hasDraftOnHackmd ?? false);
   // useIsNotCreatable(props.isForbidden || !isCreatablePage(pagePath)); // TODO: need to include props.isIdentical

+ 34 - 10
packages/app/src/pages/_document.page.tsx

@@ -73,7 +73,9 @@ const HeadersForGrowiPlugin = (props: HeadersForGrowiPluginProps): JSX.Element =
 
 interface GrowiDocumentProps {
   theme: string,
-  customCss: string;
+  customScript: string | null,
+  customCss: string | null,
+  customNoscript: string | null,
   presetThemesManifest: ViteManifest,
   pluginThemeHref: string | undefined,
   pluginResourceEntries: GrowiPluginResourceEntries;
@@ -88,7 +90,9 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
     const { configManager, customizeService, pluginService } = crowi;
 
     const theme = configManager.getConfig('crowi', 'customize:theme');
-    const customCss: string = customizeService.getCustomCss();
+    const customScript: string | null = customizeService.getCustomScript();
+    const customCss: string | null = customizeService.getCustomCss();
+    const customNoscript: string | null = customizeService.getCustomNoscript();
 
     // import preset-themes manifest
     const presetThemesManifest = await import('@growi/preset-themes/dist/themes/manifest.json').then(imported => imported.default);
@@ -100,28 +104,46 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
     return {
       ...initialProps,
       theme,
+      customScript,
       customCss,
+      customNoscript,
       presetThemesManifest,
       pluginThemeHref,
       pluginResourceEntries,
     };
   }
 
+  renderCustomScript(customScript: string | null): JSX.Element {
+    if (customScript == null || customScript.length === 0) {
+      return <></>;
+    }
+    return <script id="customScript" dangerouslySetInnerHTML={{ __html: customScript }} />;
+  }
+
+  renderCustomCss(customCss: string | null): JSX.Element {
+    if (customCss == null || customCss.length === 0) {
+      return <></>;
+    }
+    return <style dangerouslySetInnerHTML={{ __html: customCss }} />;
+  }
+
+  renderCustomNoscript(customNoscript: string | null): JSX.Element {
+    if (customNoscript == null || customNoscript.length === 0) {
+      return <></>;
+    }
+    return <noscript dangerouslySetInnerHTML={{ __html: customNoscript }} />;
+  }
+
   override render(): JSX.Element {
     const {
-      customCss, theme, presetThemesManifest, pluginThemeHref, pluginResourceEntries,
+      customCss, customScript, customNoscript,
+      theme, presetThemesManifest, pluginThemeHref, pluginResourceEntries,
     } = this.props;
 
     return (
       <Html>
         <Head>
-          <style>
-            {customCss}
-          </style>
-          {/*
-          {renderScriptTagsByGroup('basis')}
-          {renderStyleTagsByGroup('basis')}
-          */}
+          {this.renderCustomScript(customScript)}
           <link rel='preload' href="/static/fonts/PressStart2P-latin.woff2" as="font" type="font/woff2" />
           <link rel='preload' href="/static/fonts/PressStart2P-latin-ext.woff2" as="font" type="font/woff2" />
           <link rel='preload' href="/static/fonts/Lato-Regular-latin.woff2" as="font" type="font/woff2" />
@@ -131,8 +153,10 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
           <HeadersForThemes theme={theme}
             presetThemesManifest={presetThemesManifest} pluginThemeHref={pluginThemeHref} />
           <HeadersForGrowiPlugin pluginResourceEntries={pluginResourceEntries} />
+          {this.renderCustomCss(customCss)}
         </Head>
         <body>
+          {this.renderCustomNoscript(customNoscript)}
           <Main />
           <NextScript />
         </body>

+ 4 - 2
packages/app/src/server/models/config.ts

@@ -118,7 +118,7 @@ export const defaultCrowiConfigs: { [key: string]: any } = {
 
   'customize:css' : undefined,
   'customize:script' : undefined,
-  'customize:header' : undefined,
+  'customize:noscript' : undefined,
   'customize:title' : undefined,
   'customize:highlightJsStyle' : 'github',
   'customize:highlightJsStyleBorder' : false,
@@ -147,12 +147,14 @@ export const defaultCrowiConfigs: { [key: string]: any } = {
 };
 
 export const defaultMarkdownConfigs: { [key: string]: any } = {
+  // don't use it, but won't turn it off
   'markdown:xss:tagWhiteList': [],
   'markdown:xss:attrWhiteList': [],
+
   'markdown:rehypeSanitize:isEnabledPrevention': true,
   'markdown:rehypeSanitize:option': RehypeSanitizeOption.RECOMMENDED,
   'markdown:rehypeSanitize:tagNames': [],
-  'markdown:rehypeSanitize:attributes': {},
+  'markdown:rehypeSanitize:attributes': '{}',
   'markdown:isEnabledLinebreaks': false,
   'markdown:isEnabledLinebreaksInComments': true,
   'markdown:adminPreferredIndentSize': 4,

+ 22 - 22
packages/app/src/server/routes/apiv3/customize-setting.js

@@ -77,11 +77,11 @@ const multer = require('multer');
  *        properties:
  *          customizeTitle:
  *            type: string
- *      CustomizeHeader:
- *        description: CustomizeHeader
+ *      CustomizeNoscript:
+ *        description: CustomizeNoscript
  *        type: object
  *        properties:
- *          customizeHeader:
+ *          customizeNoscript:
  *            type: string
  *      CustomizeCss:
  *        description: CustomizeCss
@@ -131,20 +131,20 @@ module.exports = (crowi) => {
     customizeTitle: [
       body('customizeTitle').isString(),
     ],
-    customizeHeader: [
-      body('customizeHeader').isString(),
-    ],
     highlight: [
       body('highlightJsStyle').isString().isIn([
         'github', 'github-gist', 'atom-one-light', 'xcode', 'vs', 'atom-one-dark', 'hybrid', 'monokai', 'tomorrow-night', 'vs2015',
       ]),
       body('highlightJsStyleBorder').isBoolean(),
     ],
+    customizeScript: [
+      body('customizeScript').isString(),
+    ],
     customizeCss: [
       body('customizeCss').isString(),
     ],
-    customizeScript: [
-      body('customizeScript').isString(),
+    customizeNoscript: [
+      body('customizeNoscript').isString(),
     ],
     logo: [
       body('isDefaultLogo').isBoolean().optional({ nullable: true }),
@@ -186,9 +186,9 @@ module.exports = (crowi) => {
       styleName: await crowi.configManager.getConfig('crowi', 'customize:highlightJsStyle'),
       styleBorder: await crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
       customizeTitle: await crowi.configManager.getConfig('crowi', 'customize:title'),
-      customizeHeader: await crowi.configManager.getConfig('crowi', 'customize:header'),
-      customizeCss: await crowi.configManager.getConfig('crowi', 'customize:css'),
       customizeScript: await crowi.configManager.getConfig('crowi', 'customize:script'),
+      customizeCss: await crowi.configManager.getConfig('crowi', 'customize:css'),
+      customizeNoscript: await crowi.configManager.getConfig('crowi', 'customize:noscript'),
     };
 
     return res.apiv3({ customizeParams });
@@ -531,43 +531,43 @@ module.exports = (crowi) => {
   /**
    * @swagger
    *
-   *    /customize-setting/customizeHeader:
+   *    /customize-setting/customize-noscript:
    *      put:
    *        tags: [CustomizeSetting]
-   *        operationId: updateCustomizeHeaderCustomizeSetting
-   *        summary: /customize-setting/customizeHeader
-   *        description: Update customizeHeader
+   *        operationId: updateCustomizeNoscriptCustomizeSetting
+   *        summary: /customize-setting/customize-noscript
+   *        description: Update customizeNoscript
    *        requestBody:
    *          required: true
    *          content:
    *            application/json:
    *              schema:
-   *                $ref: '#/components/schemas/CustomizeHeader'
+   *                $ref: '#/components/schemas/CustomizeNoscript'
    *        responses:
    *          200:
    *            description: Succeeded to update customize header
    *            content:
    *              application/json:
    *                schema:
-   *                  $ref: '#/components/schemas/CustomizeHeader'
+   *                  $ref: '#/components/schemas/CustomizeNoscript'
    */
-  router.put('/customize-header', loginRequiredStrictly, adminRequired, addActivity, validator.customizeHeader, apiV3FormValidator, async(req, res) => {
+  router.put('/customize-noscript', loginRequiredStrictly, adminRequired, addActivity, validator.customizeNoscript, apiV3FormValidator, async(req, res) => {
     const requestParams = {
-      'customize:header': req.body.customizeHeader,
+      'customize:noscript': req.body.customizeNoscript,
     };
     try {
       await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
       const customizedParams = {
-        customizeHeader: await crowi.configManager.getConfig('crowi', 'customize:header'),
+        customizeNoscript: await crowi.configManager.getConfig('crowi', 'customize:noscript'),
       };
-      const parameters = { action: SupportedAction.ACTION_ADMIN_CUSTOM_HTML_HEADER_UPDATE };
+      const parameters = { action: SupportedAction.ACTION_ADMIN_CUSTOM_NOSCRIPT_UPDATE };
       activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ customizedParams });
     }
     catch (err) {
-      const msg = 'Error occurred in updating customizeHeader';
+      const msg = 'Error occurred in updating customizeNoscript';
       logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-customizeHeader-failed'));
+      return res.apiv3Err(new ErrorV3(msg, 'update-customizeNoscript-failed'));
     }
   });
 

+ 16 - 7
packages/app/src/server/routes/apiv3/markdown-setting.js

@@ -30,7 +30,7 @@ const validator = {
   xssSetting: [
     body('isEnabledXss').isBoolean(),
     body('tagWhiteList').isArray(),
-    body('attrWhiteList').isArray(),
+    body('attrWhiteList').isString(),
   ],
 };
 
@@ -127,8 +127,8 @@ module.exports = (crowi) => {
       pageBreakCustomSeparator: await crowi.configManager.getConfig('markdown', 'markdown:presentation:pageBreakCustomSeparator'),
       isEnabledXss: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
       xssOption: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
-      tagWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:xss:tagWhiteList'),
-      attrWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:xss:attrWhiteList'),
+      tagWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
+      attrWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes'),
     };
 
     return res.apiv3({ markdownParams });
@@ -292,11 +292,20 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3('xss option is required'));
     }
 
+    try {
+      JSON.parse(req.body.attrWhiteList);
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating xss';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'update-xss-failed'));
+    }
+
     const reqestXssParams = {
       'markdown:rehypeSanitize:isEnabledPrevention': req.body.isEnabledXss,
       'markdown:rehypeSanitize:option': req.body.xssOption,
-      'markdown:xss:tagWhiteList': req.body.tagWhiteList, // Todo: need to be changed at https://redmine.weseek.co.jp/issues/109763
-      'markdown:xss:attrWhiteList': req.body.attrWhiteList, // Todo: need to be changed at https://redmine.weseek.co.jp/issues/109763
+      'markdown:rehypeSanitize:tagNames': req.body.tagWhiteList,
+      'markdown:rehypeSanitize:attributes': req.body.attrWhiteList,
     };
 
     try {
@@ -304,8 +313,8 @@ module.exports = (crowi) => {
       const xssParams = {
         isEnabledXss: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
         xssOption: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
-        tagWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:xss:tagWhiteList'),
-        attrWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:xss:attrWhiteList'),
+        tagWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
+        attrWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes'),
       };
 
       const parameters = { action: SupportedAction.ACTION_ADMIN_MARKDOWN_XSS_UPDATE };

+ 8 - 5
packages/app/src/server/service/customize.ts

@@ -1,12 +1,13 @@
 // eslint-disable-next-line no-unused-vars
+import { DevidedPagePath } from '@growi/core';
 import uglifycss from 'uglifycss';
 
-import { DevidedPagePath } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 
 import S2sMessage from '../models/vo/s2s-message';
-import { S2sMessageHandlable } from './s2s-messaging/handlable';
+
 import ConfigManager from './config-manager';
+import { S2sMessageHandlable } from './s2s-messaging/handlable';
 
 const logger = loggerFactory('growi:service:CustomizeService');
 
@@ -80,8 +81,6 @@ class CustomizeService implements S2sMessageHandlable {
    * initialize custom css strings
    */
   initCustomCss() {
-    const uglifycss = require('uglifycss');
-
     const rawCss = this.configManager.getConfig('crowi', 'customize:css') || '';
 
     // uglify and store
@@ -95,7 +94,11 @@ class CustomizeService implements S2sMessageHandlable {
   }
 
   getCustomScript() {
-    return this.configManager.getConfig('crowi', 'customize:script') || '';
+    return this.configManager.getConfig('crowi', 'customize:script');
+  }
+
+  getCustomNoscript() {
+    return this.configManager.getConfig('crowi', 'customize:noscript');
   }
 
   initCustomTitle() {

+ 3 - 25
packages/app/src/stores/page-redirect.tsx

@@ -1,30 +1,8 @@
-import { SWRResponseWithUtils, withUtils } from '@growi/core/src/utils/with-utils';
 import { SWRResponse } from 'swr';
 
-import { apiPost } from '~/client/util/apiv1-client';
-
-import { useCurrentPagePath } from './page';
 import { useStaticSWR } from './use-static-swr';
 
-type RedirectFromUtil = {
-  unlink(): Promise<void>
-}
-export const useRedirectFrom = (initialData?: string): SWRResponseWithUtils<RedirectFromUtil, string> => {
-  const { data: currentPagePath } = useCurrentPagePath();
-  const swrResponse: SWRResponse<string, Error> = useStaticSWR('redirectFrom', initialData);
-  const utils = {
-    unlink: async() => {
-      if (currentPagePath == null) {
-        return;
-      }
-      try {
-        await apiPost('/pages.unlink', { path: currentPagePath });
-        swrResponse.mutate('');
-      }
-      catch (err) {
-        throw err;
-      }
-    },
-  };
-  return withUtils(swrResponse, utils);
+
+export const useRedirectFrom = (initialData?: string): SWRResponse<string, Error> => {
+  return useStaticSWR('redirectFrom', initialData);
 };

+ 8 - 11
packages/app/test/cypress/integration/20-basic-features/20-basic-features--access-to-page.spec.ts

@@ -6,8 +6,6 @@ context('Access to page', () => {
     cy.fixture("user-admin.json").then(user => {
       cy.login(user.username, user.password);
     });
-    // collapse sidebar
-    cy.collapseSidebar(true);
   });
 
   it('/Sandbox is successfully loaded', () => {
@@ -17,6 +15,7 @@ context('Access to page', () => {
     // for check download toc data
     cy.get('.toc-link').eq(0).contains('Table of Contents');
 
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}-sandbox`);
   });
 
@@ -46,6 +45,7 @@ context('Access to page', () => {
     // for check download toc data
     cy.get('.toc-link').should('be.visible');
 
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}-sandbox-math`);
   });
 
@@ -77,7 +77,7 @@ context('Access to page', () => {
 
     // eslint-disable-next-line cypress/no-unnecessary-waiting
     cy.wait(2000); // wait for calcViewHeight and rendering
-
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}-user-admin`);
   });
 
@@ -92,8 +92,6 @@ context('Access to /me page', () => {
     cy.fixture("user-admin.json").then(user => {
       cy.login(user.username, user.password);
     });
-    // collapse sidebar
-    cy.collapseSidebar(true);
   });
 
   it('/me is successfully loaded', () => {
@@ -101,6 +99,7 @@ context('Access to /me page', () => {
 
     cy.getByTestid('grw-user-settings').should('be.visible');
 
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}-me`);
   });
 
@@ -119,8 +118,6 @@ context('Access to special pages', () => {
     cy.fixture("user-admin.json").then(user => {
       cy.login(user.username, user.password);
     });
-    // collapse sidebar
-    cy.collapseSidebar(true);
   });
 
   it('/trash is successfully loaded', () => {
@@ -128,6 +125,7 @@ context('Access to special pages', () => {
 
     cy.getByTestid('trash-page-list').contains('There are no pages under this page.');
 
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}-trash`);
   });
 
@@ -147,6 +145,7 @@ context('Access to special pages', () => {
       cy.getByTestid('grw-tags-list').contains('You have no tag, You can set tags on pages');
     });
 
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}-tags`);
   });
 
@@ -160,8 +159,6 @@ context('Access to Template Editing Mode', () => {
     cy.fixture("user-admin.json").then(user => {
       cy.login(user.username, user.password);
     });
-    // collapse sidebar
-    cy.collapseSidebar(true);
   });
 
   // TODO: 109057
@@ -226,8 +223,6 @@ context('Access to /me/all-in-app-notifications', () => {
     cy.fixture("user-admin.json").then(user => {
       cy.login(user.username, user.password);
     });
-    // collapse sidebar
-    cy.collapseSidebar(true);
   });
 
   it('All In-App Notification list is successfully loaded', { scrollBehavior: false },() => {
@@ -238,11 +233,13 @@ context('Access to /me/all-in-app-notifications', () => {
     cy.getByTestid('grw-in-app-notification-page').should('be.visible');
     cy.getByTestid('grw-in-app-notification-page-spinner').should('not.exist');
 
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}-see-all`);
 
     cy.get('.grw-custom-nav-tab > div > ul > li:nth-child(2) > a').click();
     cy.getByTestid('grw-in-app-notification-page-spinner').should('not.exist');
 
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}-see-unread`);
    });
 

+ 11 - 8
packages/app/test/cypress/integration/20-basic-features/20-basic-features--access-to-pagelist.spec.ts

@@ -5,13 +5,12 @@ context('Access to pagelist', () => {
     cy.fixture("user-admin.json").then(user => {
       cy.login(user.username, user.password);
     });
-    // collapse sidebar
-    cy.collapseSidebar(true);
   });
 
   it('Page list modal is successfully opened ', () => {
     cy.visit('/');
     cy.waitUntilSkeletonDisappear();
+    cy.collapseSidebar(true);
 
     cy.getByTestid('pageListButton').click({force: true});
     cy.getByTestid('page-accessories-modal').parent().should('have.class','show');
@@ -55,8 +54,9 @@ context('Access to pagelist', () => {
 
   it('Successfully expand and close modal', () => {
     cy.visit('/');
-
     cy.waitUntilSkeletonDisappear();
+    cy.collapseSidebar(true);
+
     cy.getByTestid('pageListButton').click({force: true});
     cy.getByTestid('page-accessories-modal').parent().should('have.class','show');
     cy.getByTestid('page-list-item-L').should('be.visible');
@@ -77,6 +77,7 @@ context('Access to pagelist', () => {
       cy.get('button.close').eq(1).click();
     });
 
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}8-close-page-list-modal`);
   });
 });
@@ -88,22 +89,24 @@ context('Access to timeline', () => {
     cy.fixture("user-admin.json").then(user => {
       cy.login(user.username, user.password);
     });
-    // collapse sidebar
-    cy.collapseSidebar(true);
   });
   it('Timeline list successfully openend', () => {
     cy.visit('/');
+    cy.collapseSidebar(true);
+
     cy.getByTestid('pageListButton').click({force: true});
     cy.getByTestid('page-accessories-modal').parent().should('have.class','show').within(() => {
       cy.get('.nav-title > li').eq(1).find('a').click();
     });
     // eslint-disable-next-line cypress/no-unnecessary-waiting
     cy.wait(500); // wait for loading wiki
-    cy.screenshot(`${ssPrefix}1-timeline-list`, {capture: 'viewport'});
+    cy.screenshot(`${ssPrefix}1-timeline-list`);
   });
 
   it('Successfully expand and close modal', () => {
     cy.visit('/');
+    cy.collapseSidebar(true);
+
     cy.getByTestid('pageListButton').click({force: true});
     cy.getByTestid('page-accessories-modal').parent().should('have.class','show').within(() => {
       cy.get('.nav-title > li').eq(1).find('a').click();
@@ -112,10 +115,10 @@ context('Access to timeline', () => {
     cy.get('.modal').should('be.visible');
     // eslint-disable-next-line cypress/no-unnecessary-waiting
     cy.wait(500); // wait for loading wiki
-    cy.screenshot(`${ssPrefix}2-timeline-list-fullscreen`, {capture: 'viewport'});
+    cy.screenshot(`${ssPrefix}2-timeline-list-fullscreen`);
     cy.getByTestid('page-accessories-modal').parent().should('have.class','show').within(() => {
       cy.get('button.close').eq(1).click();
     });
-    cy.screenshot(`${ssPrefix}3-close-modal`, {capture: 'viewport'});
+    cy.screenshot(`${ssPrefix}3-close-modal`);
   });
 });

+ 67 - 51
packages/app/test/cypress/integration/20-basic-features/20-basic-features--use-tools.spec.ts

@@ -1,4 +1,4 @@
-context('Switch Sidebar content', () => {
+context('Switch Sidebar content', { scrollBehavior: false }, () => {
   const ssPrefix = 'switch-sidebar-content';
 
   beforeEach(() => {
@@ -9,8 +9,8 @@ context('Switch Sidebar content', () => {
   });
 
   it('PageTree is successfully shown', () => {
-    cy.collapseSidebar(false);
     cy.visit('/page');
+    cy.collapseSidebar(false);
     cy.waitUntilSkeletonDisappear();
 
     cy.getByTestid('grw-sidebar-nav-primary-page-tree').click();
@@ -29,7 +29,6 @@ context('Modal for page operation', () => {
     cy.fixture("user-admin.json").then(user => {
       cy.login(user.username, user.password);
     });
-    cy.collapseSidebar(true);
   });
 
   it("PageCreateModal is shown and closed successfully", () => {
@@ -39,12 +38,14 @@ context('Modal for page operation', () => {
     cy.getByTestid('newPageBtn').click();
 
     // eslint-disable-next-line cypress/no-unnecessary-waiting
-    cy.wait(500) // Wait for animation to finish when the Create Page button is pressed
+    cy.wait(1000) // Wait for animation to finish when the Create Page button is pressed
 
     cy.getByTestid('page-create-modal').should('be.visible').within(() => {
       cy.screenshot(`${ssPrefix}new-page-modal-opened`);
       cy.get('button.close').click();
     });
+
+    cy.collapseSidebar(true, true);
     cy.screenshot(`${ssPrefix}page-create-modal-closed`);
   });
 
@@ -69,9 +70,8 @@ context('Modal for page operation', () => {
     // eslint-disable-next-line cypress/no-unnecessary-waiting
     cy.wait(300);
 
-    // Do not use "cy.waitUntilSkeletonDisappear()"
-    cy.get('.grw-skeleton').should('not.exist');
-
+    cy.collapseSidebar(true, true);
+    cy.waitUntilSkeletonDisappear();
     cy.screenshot(`${ssPrefix}create-today-page`);
   });
 
@@ -81,6 +81,9 @@ context('Modal for page operation', () => {
     cy.visit('/Sandbox');
     cy.waitUntilSkeletonDisappear();
 
+    // eslint-disable-next-line cypress/no-unnecessary-waiting
+    cy.wait(1000);
+
     cy.getByTestid('newPageBtn').click();
 
     cy.getByTestid('page-create-modal').should('be.visible').within(() => {
@@ -98,9 +101,8 @@ context('Modal for page operation', () => {
     // eslint-disable-next-line cypress/no-unnecessary-waiting
     cy.wait(300);
 
-    // Do not use "cy.waitUntilSkeletonDisappear()"
-    cy.get('.grw-skeleton').should('not.exist');
-
+    cy.waitUntilSkeletonDisappear();
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}create-page-under-specific-page`);
   });
 
@@ -119,6 +121,7 @@ context('Modal for page operation', () => {
       cy.getByTestid('grw-btn-edit-page').should('be.visible').click();
     });
     cy.get('.toast-error').should('be.visible').invoke('attr', 'style', 'opacity: 1');
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}create-template-for-children-error`);
     cy.get('.toast-error').should('be.visible').click();
     cy.get('.toast-error').should('not.exist');
@@ -129,10 +132,11 @@ context('Modal for page operation', () => {
       cy.getByTestid('grw-btn-edit-page').should('be.visible').click();
     });
     cy.get('.toast-error').should('be.visible').invoke('attr', 'style', 'opacity: 1');
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}create-template-for-descendants-error`);
   });
 
-  it('Page Deletion and PutBack is executed successfully', () => {
+  it('Page Deletion and PutBack is executed successfully', { scrollBehavior: false }, () => {
     cy.visit('/Sandbox/Bootstrap4');
     cy.waitUntilSkeletonDisappear();
 
@@ -146,6 +150,7 @@ context('Modal for page operation', () => {
       cy.getByTestid('delete-page-button').click();
     });
     cy.getByTestid('trash-page-alert').should('be.visible');
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}-bootstrap4-is-in-garbage-box`);
 
     cy.getByTestid('put-back-button').click();
@@ -153,6 +158,8 @@ context('Modal for page operation', () => {
       cy.screenshot(`${ssPrefix}-put-back-modal`);
       cy.getByTestid('put-back-execution-button').should('be.visible').click();
     });
+
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}-put-backed-bootstrap4-page`);
   });
 
@@ -222,7 +229,6 @@ context('Page Accessories Modal', () => {
     cy.fixture("user-admin.json").then(user => {
       cy.login(user.username, user.password);
     });
-    cy.collapseSidebar(true);
   });
 
   it('Page History is shown successfully', () => {
@@ -236,7 +242,8 @@ context('Page Accessories Modal', () => {
       cy.getByTestid('open-page-accessories-modal-btn-with-history-tab').click({force: true});
     });
 
-     cy.getByTestid('page-history').should('be.visible')
+     cy.getByTestid('page-history').should('be.visible');
+     cy.collapseSidebar(true);
      cy.screenshot(`${ssPrefix}-open-page-history-bootstrap4`);
   });
 
@@ -252,6 +259,8 @@ context('Page Accessories Modal', () => {
     });
 
      cy.getByTestid('page-attachment').should('be.visible').contains('No attachments yet.');
+
+     cy.collapseSidebar(true);
      cy.screenshot(`${ssPrefix}-open-page-attachment-data-bootstrap4`);
   });
 
@@ -269,6 +278,8 @@ context('Page Accessories Modal', () => {
 
    cy.getByTestid('page-accessories-modal').should('be.visible');
    cy.getByTestid('share-link-management').should('be.visible');
+
+   cy.collapseSidebar(true);
    cy.screenshot(`${ssPrefix}-open-share-link-management-bootstrap4`);
   });
 });
@@ -280,7 +291,6 @@ context('Tag Oprations', { scrollBehavior: false }, () =>{
     cy.fixture("user-admin.json").then(user => {
       cy.login(user.username, user.password);
     });
-    cy.collapseSidebar(true);
   });
 
   it('Successfully add new tag', () => {
@@ -318,6 +328,7 @@ context('Tag Oprations', { scrollBehavior: false }, () =>{
     cy.get('.grw-taglabels-container > .grw-tag-labels > a').contains(tag).should('exist');
     /* eslint-disable cypress/no-unnecessary-waiting */
     cy.wait(150); // wait for toastr to change its color occured by mouseover
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}4-click-done`);
   });
 
@@ -339,6 +350,7 @@ context('Tag Oprations', { scrollBehavior: false }, () =>{
 
     // force to add 'active' to pass VRT: https://github.com/weseek/growi/pull/6603
     cy.getByTestid('page-list-item-L').first().invoke('addClass', 'active');
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}1-click-tag-name`);
     cy.getByTestid('search-result-list').should('be.visible').then(($el)=>{
       cy.wrap($el).within(()=>{
@@ -347,6 +359,7 @@ context('Tag Oprations', { scrollBehavior: false }, () =>{
 
       // eslint-disable-next-line cypress/no-unnecessary-waiting
       cy.wait(1500); // for wait rendering pagelist info
+      cy.collapseSidebar(true);
       cy.screenshot(`${ssPrefix}2-click-three-dots-menu`);
 
       cy.wrap($el).within(()=>{
@@ -369,6 +382,7 @@ context('Tag Oprations', { scrollBehavior: false }, () =>{
 
     cy.visit(`Sandbox-${newPageName}`);
     cy.waitUntilSkeletonDisappear();
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}4-duplicated-page`);
   });
 
@@ -391,6 +405,7 @@ context('Tag Oprations', { scrollBehavior: false }, () =>{
 
     // eslint-disable-next-line cypress/no-unnecessary-waiting
     cy.wait(300);
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}1-click-tag-name`);
 
     cy.getByTestid('search-result-list').within(() => {
@@ -437,53 +452,54 @@ context('Tag Oprations', { scrollBehavior: false }, () =>{
     cy.waitUntilSkeletonDisappear();
 
     cy.getByTestid('grw-tag-labels').should('be.visible')
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}4-new-page-name-applied`);
   });
 });
 
-context('Shortcuts', () => {
-  const ssPrefix = 'shortcuts';
+// context('Shortcuts', () => {
+//   const ssPrefix = 'shortcuts';
 
-  beforeEach(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-  });
+//   beforeEach(() => {
+//     // login
+//     cy.fixture("user-admin.json").then(user => {
+//       cy.login(user.username, user.password);
+//     });
+//   });
 
-  it('Successfully updating a page using a shortcut on a previously created page', () => {
-    const body1 = 'hello';
-    const body2 = 'world';
-    const savePageShortcutKey = '{ctrl+s}'
+//   it('Successfully updating a page using a shortcut on a previously created page', { scrollBehavior: false }, () => {
+//     const body1 = 'hello';
+//     const body2 = 'world';
+//     const savePageShortcutKey = '{ctrl+s}';
 
-    cy.visit('/Sandbox/child');
-    cy.waitUntilSkeletonDisappear();
+//     cy.visit('/Sandbox/child');
+//     cy.waitUntilSkeletonDisappear();
 
-    cy.get('#grw-subnav-container').within(() => {
-      cy.getByTestid('editor-button').should('be.visible').click();
-    })
+//     cy.get('#grw-subnav-container').within(() => {
+//       cy.getByTestid('editor-button').click();
+//     });
 
-    cy.get('.layout-root').should('have.class', 'editing');
-    cy.get('.grw-editor-navbar-bottom').should('be.visible');
+//     cy.get('.layout-root').should('have.class', 'editing');
+//     cy.get('.grw-editor-navbar-bottom').should('be.visible');
 
-    // 1st
-    cy.get('.CodeMirror').type(body1);
-    cy.get('.CodeMirror').contains(body1);
-    cy.get('.page-editor-preview-body').contains(body1);
-    cy.get('.CodeMirror').type(savePageShortcutKey);
+//     // 1st
+//     cy.get('.CodeMirror').type(body1);
+//     cy.get('.CodeMirror').contains(body1);
+//     cy.get('.page-editor-preview-body').contains(body1);
+//     cy.get('.CodeMirror').type(savePageShortcutKey);
 
-    cy.get('.toast').should('be.visible').trigger('mouseover');
-    cy.screenshot(`${ssPrefix}-update-page-1`);
-    cy.get('.toast-close-button').click();
-    cy.get('.toast').should('not.exist');
+//     cy.get('.Toastify').should('visible').trigger('mouseover');
+//     cy.screenshot(`${ssPrefix}-update-page-1`);
+//     cy.get('.Toastify__close-button').should('be.visible').click();
+//     cy.get('.Toastify').should('not.be.visible');
 
-    // 2nd
-    cy.get('.CodeMirror').type(body2);
-    cy.get('.CodeMirror').contains(body2);
-    cy.get('.page-editor-preview-body').contains(body2);
-    cy.get('.CodeMirror').type(savePageShortcutKey);
+//     // 2nd
+//     cy.get('.CodeMirror').type(body2);
+//     cy.get('.CodeMirror').contains(body2);
+//     cy.get('.page-editor-preview-body').contains(body2);
+//     cy.get('.CodeMirror').type(savePageShortcutKey);
 
-    cy.get('.toast').should('be.visible').trigger('mouseover');
-    cy.screenshot(`${ssPrefix}-update-page-2`);
-  });
-});
+//     cy.get('.Toastify').should('visible').trigger('mouseover');
+//     cy.screenshot(`${ssPrefix}-update-page-2`);
+//   });
+// });

+ 7 - 12
packages/app/test/cypress/integration/21-basic-features-for-guest/21-basic-features-for-guest--access-to-page.spec.ts

@@ -3,16 +3,14 @@ context('Access to page by guest', () => {
 
   it('/Sandbox is successfully loaded', () => {
     cy.visit('/Sandbox');
-    cy.waitUntilSpinnerDisappear();
     cy.getByTestid('grw-pagetree-item-container').should('be.visible');
-    cy.collapseSidebar(true, true);
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}-sandbox`);
   });
 
   it('/Sandbox with anchor hash is successfully loaded', () => {
     cy.visit('/Sandbox#Headers');
     cy.getByTestid('grw-pagetree-item-container').should('be.visible');
-    cy.collapseSidebar(true, true);
 
     // eslint-disable-next-line cypress/no-unnecessary-waiting
     // cy.wait(500);
@@ -20,16 +18,17 @@ context('Access to page by guest', () => {
     // hide fab // disable fab for sticky-events warning
     // cy.getByTestid('grw-fab-container').invoke('attr', 'style', 'display: none');
 
+    cy.collapseSidebar(true, true);
     cy.screenshot(`${ssPrefix}-sandbox-headers`);
   });
 
   it('/Sandbox/Math is successfully loaded', () => {
     cy.visit('/Sandbox/Math');
     cy.getByTestid('revision-toc-content').should('be.visible');
-    cy.collapseSidebar(true, true);
 
     cy.get('.math').should('be.visible');
 
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}-sandbox-math`, {
       blackout: ['.revision-toc', '[data-hide-in-vrt=true]']
     });
@@ -37,11 +36,11 @@ context('Access to page by guest', () => {
 
   it('/Sandbox with edit is successfully loaded', () => {
     cy.visit('/Sandbox#edit');
-    cy.collapseSidebar(true, true);
 
     // eslint-disable-next-line cypress/no-unnecessary-waiting
     cy.wait(1000);
 
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}-sandbox-edit-page`);
   })
 
@@ -51,13 +50,9 @@ context('Access to page by guest', () => {
 context('Access to /me page', () => {
   const ssPrefix = 'access-to-me-page-by-guest-';
 
-  beforeEach(() => {
-    // collapse sidebar
-    cy.collapseSidebar(true);
-  });
-
   it('/me should be redirected to /login', () => {
-    cy.visit('/me', {  });
+    cy.visit('/me');
+    cy.getByTestid('login-form').should('be.visible');
     cy.screenshot(`${ssPrefix}-me`);
   });
 
@@ -69,8 +64,8 @@ context('Access to special pages by guest', () => {
 
   it('/trash is successfully loaded', () => {
     cy.visit('/trash', {  });
-    cy.collapseSidebar(true, true);
     cy.getByTestid('trash-page-list').should('be.visible');
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}-trash`);
   });
 

+ 14 - 12
packages/app/test/cypress/integration/60-home/60-home--home.spec.ts

@@ -6,27 +6,30 @@ context('Access Home', () => {
     cy.fixture("user-admin.json").then(user => {
       cy.login(user.username, user.password);
     });
-    // collapse sidebar
-    cy.collapseSidebar(true);
   });
 
   it('Visit home', () => {
     cy.visit('/dummy');
-    cy.waitUntilSkeletonDisappear();
-    cy.get('.grw-personal-dropdown').as('dropdown').should('be.visible').click()
-    cy.get('@dropdown').within(()=>{
-      cy.getByTestid('personal-dropdown-menu').should('have.css', 'display', 'block');
+
+    // open PersonalDropdown
+    cy.waitUntil(() => {
+      // do
+      cy.getByTestid('personal-dropdown-button').should('be.visible').click();
+      // wait until
+      return cy.getByTestid('grw-personal-dropdown-menu-user-home').then($elem => $elem.is(':visible'));
     });
+    // click the Home button
     cy.getByTestid('grw-personal-dropdown-menu-user-home').should('be.visible').click();
-    cy.waitUntilSkeletonDisappear();
 
-    // eslint-disable-next-line cypress/no-unnecessary-waiting
-    cy.wait(2000); // wait for calcViewHeight and rendering
+    cy.getByTestid('grw-users-info').should('be.visible');
 
     // for check download toc data
-    cy.get('.toc-link', { timeout: 60000 }).should('be.visible');
+    // https://redmine.weseek.co.jp/issues/111384
+    // cy.get('.toc-link').should('be.visible');
 
     // same screenshot is taken in access-to-page.spec
+    cy.collapseSidebar(true);
+    cy.waitUntilSkeletonDisappear();
     cy.screenshot(`${ssPrefix}-visit-home`);
   });
 
@@ -41,9 +44,8 @@ context('Access User settings', () => {
     cy.fixture("user-admin.json").then(user => {
       cy.login(user.username, user.password);
     });
-    // collapse sidebar
-    cy.collapseSidebar(true);
     cy.visit('/me');
+    cy.collapseSidebar(true);
     // hide fab // disable fab for sticky-events warning
     // cy.getByTestid('grw-fab-container').invoke('attr', 'style', 'display: none');
   });

+ 34 - 23
packages/app/test/cypress/support/commands.ts

@@ -24,6 +24,20 @@
 // -- This will overwrite an existing command --
 // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
 
+import 'cypress-wait-until';
+
+function isVisible($elem: JQuery<Element>) {
+  return $elem.is(':visible');
+}
+function isHidden($elem: JQuery<Element>) {
+  return !isVisible($elem);
+}
+function isVisibleByTestId(testId: string) {
+  return isVisible(Cypress.$(`[data-testid=${testId}]`));
+}
+function isHiddenByTestId(testId: string) {
+  return !isVisibleByTestId(testId);
+}
 
 Cypress.Commands.add('getByTestid', (selector, options?) => {
   return cy.get(`[data-testid=${selector}]`, options);
@@ -41,43 +55,40 @@ Cypress.Commands.add('login', (username, password) => {
   });
 });
 
-/**
- * use only for the pages which use component with skeleton
- */
 Cypress.Commands.add('waitUntilSkeletonDisappear', () => {
-  cy.get('.grw-skeleton').should('exist');
+  if (isHidden(Cypress.$('.grw-skeleton'))) {
+    return;
+  }
   cy.get('.grw-skeleton').should('not.exist');
 });
 
 Cypress.Commands.add('waitUntilSpinnerDisappear', () => {
-  cy.get('.fa-spinner').should('exist');
+  if (isHidden(Cypress.$('.fa-spinner'))) {
+    return;
+  }
   cy.get('.fa-spinner').should('not.exist');
 });
 
-let isSidebarCollapsed: boolean | undefined;
-
-Cypress.Commands.add('collapseSidebar', (isCollapsed, force=false) => {
+Cypress.Commands.add('collapseSidebar', (isCollapsed: boolean) => {
+  const isSidebarExists = isVisibleByTestId('grw-sidebar-wrapper');
 
-  if (!force && isSidebarCollapsed === isCollapsed) {
+  if (!isSidebarExists) {
     return;
   }
 
-  const isGrowiPage = Cypress.$('div.growi').length > 0;
-  if (!isGrowiPage) {
-    cy.visit('/page-to-toggle-sidebar-collapsed');
+  const isSidebarContextualNavigationHidden = isHiddenByTestId('grw-contextual-navigation-sub');
+  if (isSidebarContextualNavigationHidden === isCollapsed) {
+    return;
   }
 
-  cy.getByTestid('grw-contextual-navigation-sub').then(($contents) => {
-    const isCurrentCollapsed = $contents.hasClass('d-none');
-    // toggle when the current state and isCoolapsed is not match
-    if (isCurrentCollapsed !== isCollapsed) {
-      cy.getByTestid("grw-navigation-resize-button").click({force: true});
+  cy.waitUntil(() => {
+    // do
+    cy.getByTestid("grw-navigation-resize-button").click({force: true});
+    // wait until saving UserUISettings
+    // eslint-disable-next-line cypress/no-unnecessary-waiting
+    cy.wait(1500);
 
-      // wait until saving UserUISettings
-      // eslint-disable-next-line cypress/no-unnecessary-waiting
-      cy.wait(1500);
-    }
+    // wait until
+    return cy.getByTestid('grw-contextual-navigation-sub').then($contents => isHidden($contents) === isCollapsed);
   });
-
-  isSidebarCollapsed = isCollapsed;
 });

+ 5 - 0
yarn.lock

@@ -7800,6 +7800,11 @@ currently-unhandled@^0.4.1:
   dependencies:
     array-find-index "^1.0.1"
 
+cypress-wait-until@^1.7.2:
+  version "1.7.2"
+  resolved "https://registry.yarnpkg.com/cypress-wait-until/-/cypress-wait-until-1.7.2.tgz#7f534dd5a11c89b65359e7a0210f20d3dfc22107"
+  integrity sha512-uZ+M8/MqRcpf+FII/UZrU7g1qYZ4aVlHcgyVopnladyoBrpoaMJ4PKZDrdOJ05H5RHbr7s9Tid635X3E+ZLU/Q==
+
 cypress@^12.0.1:
   version "12.0.1"
   resolved "https://registry.yarnpkg.com/cypress/-/cypress-12.0.1.tgz#3a51a38b2f162256c7226e68e902cfe1750e3d92"