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

Merge branch 'master' into imprv/sidebar-skelton-pagetree

kymn 3 лет назад
Родитель
Сommit
9daf070fe8
31 измененных файлов с 287 добавлено и 155 удалено
  1. 1 1
      packages/app/public/static/locales/en_US/translation.json
  2. 1 1
      packages/app/public/static/locales/ja_JP/translation.json
  3. 1 1
      packages/app/public/static/locales/zh_CN/translation.json
  4. 1 1
      packages/app/src/client/interfaces/global-notification.ts
  5. 8 0
      packages/app/src/client/interfaces/notification.ts
  6. 1 1
      packages/app/src/components/Admin/Notification/GlobalNotificationList.jsx
  7. 0 43
      packages/app/src/components/Admin/Notification/NotificationTypeIcon.jsx
  8. 30 0
      packages/app/src/components/Admin/Notification/NotificationTypeIcon.tsx
  9. 1 1
      packages/app/src/components/Admin/Notification/UserNotificationRow.jsx
  10. 24 0
      packages/app/src/components/CompleteUserRegistration.tsx
  11. 24 5
      packages/app/src/components/CompleteUserRegistrationForm.tsx
  12. 20 10
      packages/app/src/components/LoginForm.tsx
  13. 14 3
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  14. 1 0
      packages/app/src/components/Navbar/PageEditorModeManager.jsx
  15. 23 18
      packages/app/src/components/Navbar/SubNavButtons.tsx
  16. 1 1
      packages/app/src/components/PageEditor/Editor.tsx
  17. 14 3
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  18. 7 0
      packages/app/src/interfaces/registration-mode.ts
  19. 5 5
      packages/app/src/pages/[[...path]].page.tsx
  20. 5 1
      packages/app/src/pages/_search.page.tsx
  21. 4 3
      packages/app/src/pages/login.page.tsx
  22. 4 0
      packages/app/src/pages/user-activation.page.tsx
  23. 1 0
      packages/app/src/server/routes/apiv3/index.js
  24. 30 1
      packages/app/src/server/routes/apiv3/user-activation.ts
  25. 44 39
      packages/app/src/server/routes/login.js
  26. 0 7
      packages/app/src/server/service/page.ts
  27. 4 0
      packages/app/src/stores/context.tsx
  28. 7 2
      packages/app/test/cypress/integration/20-basic-features/access-to-page.spec.ts
  29. 2 1
      packages/app/test/cypress/integration/20-basic-features/use-tools.spec.ts
  30. 9 6
      packages/app/test/cypress/integration/30-search/search.spec.ts
  31. 0 1
      packages/core/src/interfaces/page.ts

+ 1 - 1
packages/app/public/static/locales/en_US/translation.json

@@ -627,7 +627,7 @@
   },
   "login": {
     "Sign in error": "Login error",
-    "Registration successful": "Registration successful",
+    "Registration successful": "Registration successful. Please wait for administrator approval.",
     "Setup": "Setup",
     "enabled_ldap_has_configuration_problem":"LDAP is enabled but the configuration has something wrong.",
     "set_env_var_for_logs": "(Please set the environment variables <code>DEBUG=crowi:service:PassportService</code> to get the logs)"

+ 1 - 1
packages/app/public/static/locales/ja_JP/translation.json

@@ -621,7 +621,7 @@
   },
   "login": {
     "Sign in error": "ログインエラー",
-    "Registration successful": "登録完了",
+    "Registration successful": "登録完了しました。管理者の承認をお待ちください。",
     "Setup": "セットアップ",
     "enabled_ldap_has_configuration_problem":"LDAPは有効ですが、設定に問題があります。",
     "set_env_var_for_logs": "(ログを取得するためには、環境変数 <code>DEBUG=crowi:service:PassportService</code> を設定してください。)"

+ 1 - 1
packages/app/public/static/locales/zh_CN/translation.json

@@ -629,7 +629,7 @@
   },
 	"login": {
 		"Sign in error": "登录错误",
-		"Registration successful": "注册成功",
+		"Registration successful": "注册成功。请等待管理员批准",
 		"Setup": "安装程序",
     "enabled_ldap_has_configuration_problem":"启用了LDAP,但配置有问题。",
     "set_env_var_for_logs": "(请设置环境变量 <code>DEBUG=crowi:service:PassportService</code> 以获得日志。)"

+ 1 - 1
packages/app/src/client/interfaces/global-notification.ts

@@ -1,5 +1,5 @@
 export const NotifyType = {
-  Email: 'email',
+  Email: 'mail',
   SLACK: 'slack',
 } as const;
 

+ 8 - 0
packages/app/src/client/interfaces/notification.ts

@@ -0,0 +1,8 @@
+import type { NotifyType } from './global-notification';
+
+export type INotificationType = {
+  __t?: NotifyType
+  _id: string
+  // TOOD: Define the provider type
+  provider?: any
+}

+ 1 - 1
packages/app/src/components/Admin/Notification/GlobalNotificationList.jsx

@@ -12,7 +12,7 @@ import loggerFactory from '~/utils/logger';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 import NotificationDeleteModal from './NotificationDeleteModal';
-import NotificationTypeIcon from './NotificationTypeIcon';
+import { NotificationTypeIcon } from './NotificationTypeIcon';
 
 
 const logger = loggerFactory('growi:GolobalNotificationList');

+ 0 - 43
packages/app/src/components/Admin/Notification/NotificationTypeIcon.jsx

@@ -1,43 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { UncontrolledTooltip } from 'reactstrap';
-
-const SlackIcon = (props) => {
-  const { __t, _id, provider } = props.notification;
-
-  let type = 'slack';
-
-  // User trigger notification
-  if (provider != null) {
-    // only slack type
-  }
-
-  // Global notification
-  if (__t != null) {
-    if (__t === 'mail') {
-      type = 'mail';
-    }
-  }
-
-  const elemId = `notification-${type}-${_id}`;
-  const className = type === 'mail'
-    ? 'icon-fw fa fa-envelope-o'
-    : 'icon-fw fa fa-hashtag';
-
-  return (
-    <>
-      <i id={elemId} className={className}></i>
-      <UncontrolledTooltip target={elemId}>Slack</UncontrolledTooltip>
-    </>
-  );
-};
-
-SlackIcon.propTypes = {
-  // supports 2 types:
-  //   User trigger notification -> has 'provider: slack'
-  //   Global notification -> has '__t: slack|mail'
-  notification: PropTypes.object.isRequired,
-};
-
-export default SlackIcon;

+ 30 - 0
packages/app/src/components/Admin/Notification/NotificationTypeIcon.tsx

@@ -0,0 +1,30 @@
+import React from 'react';
+
+import { UncontrolledTooltip } from 'reactstrap';
+
+import type { INotificationType } from '~/client/interfaces/notification';
+
+
+type NotificationTypeIconProps = {
+  // supports 2 types:
+  //   User trigger notification -> has 'provider: slack'
+  //   Global notification -> has '__t: slack|mail'
+  notification: INotificationType
+}
+
+export const NotificationTypeIcon = (props: NotificationTypeIconProps): JSX.Element => {
+  const { __t, _id, provider } = props.notification;
+
+  const type = __t != null && __t === 'mail' ? 'mail' : 'slack';
+
+  // User trigger notification
+  if (provider != null) {
+    // only slack type
+  }
+
+  const elemId = `notification-${type}-${_id}`;
+  const className = type === 'mail' ? 'icon-fw fa fa-envelope-o' : 'icon-fw fa fa-hashtag';
+  const toolChip = type === 'mail' ? 'Mail' : 'Slack';
+
+  return <><i id={elemId} className={className}></i><UncontrolledTooltip target={elemId}>{toolChip}</UncontrolledTooltip></>;
+};

+ 1 - 1
packages/app/src/components/Admin/Notification/UserNotificationRow.jsx

@@ -7,7 +7,7 @@ import AdminNotificationContainer from '~/client/services/AdminNotificationConta
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
-import NotificationTypeIcon from './NotificationTypeIcon';
+import { NotificationTypeIcon } from './NotificationTypeIcon';
 
 class UserNotificationRow extends React.PureComponent {
 

+ 24 - 0
packages/app/src/components/CompleteUserRegistration.tsx

@@ -0,0 +1,24 @@
+import React, { FC } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
+
+export const CompleteUserRegistration: FC = () => {
+  const { t } = useTranslation();
+
+  return (
+    <div className="noLogin-dialog mx-auto" id="noLogin-dialog">
+      <div className="row mx-0">
+        <div className="col-12 mb-3 text-center">
+          <p className="alert alert-success">
+            <span>{t('login.Registration successful')}</span>
+          </p>
+          {/* If the transition source is "/login", use <a /> tag since the transition will not occur if next/link is used. */}
+          <a href='/login'>
+            <i className="icon-login mr-1" />{t('Sign in is here')}
+          </a>
+        </div>
+      </div>
+    </div>
+  );
+};

+ 24 - 5
packages/app/src/components/CompleteUserRegistrationForm.tsx

@@ -1,16 +1,21 @@
 import React, { useState, useEffect, useCallback } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import { useRouter } from 'next/router';
 
 import { apiv3Get, apiv3Post } from '~/client/util/apiv3-client';
 import { UserActivationErrorCode } from '~/interfaces/errors/user-activation';
+import { RegistrationMode } from '~/interfaces/registration-mode';
 
-import { toastSuccess, toastError } from '../client/util/apiNotification';
+import { toastError } from '../client/util/apiNotification';
+
+import { CompleteUserRegistration } from './CompleteUserRegistration';
 
 interface Props {
   email: string,
   token: string,
   errorCode?: UserActivationErrorCode,
+  registrationMode: RegistrationMode,
   isEmailAuthenticationEnabled: boolean,
 }
 
@@ -21,6 +26,7 @@ const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
     email,
     token,
     errorCode,
+    registrationMode,
     isEmailAuthenticationEnabled,
   } = props;
 
@@ -31,6 +37,9 @@ const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
   const [name, setName] = useState('');
   const [password, setPassword] = useState('');
   const [disableForm, setDisableForm] = useState(forceDisableForm);
+  const [isSuccessToRagistration, setIsSuccessToRagistration] = useState(false);
+
+  const router = useRouter();
 
   useEffect(() => {
     const delayDebounceFn = setTimeout(async() => {
@@ -52,17 +61,27 @@ const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
     e.preventDefault();
     setDisableForm(true);
     try {
-      await apiv3Post('/complete-registration', {
+      const res = await apiv3Post('/complete-registration', {
         username, name, password, token,
       });
-      toastSuccess('Registration succeed');
-      window.location.href = '/login';
+
+      setIsSuccessToRagistration(true);
+
+      const { redirectTo } = res.data;
+      if (redirectTo != null) {
+        router.push(redirectTo);
+      }
     }
     catch (err) {
       toastError(err, 'Registration failed');
       setDisableForm(false);
+      setIsSuccessToRagistration(false);
     }
-  }, [name, password, token, username]);
+  }, [username, name, password, token, router]);
+
+  if (isSuccessToRagistration && registrationMode === RegistrationMode.RESTRICTED) {
+    return <CompleteUserRegistration />;
+  }
 
   return (
     <>

+ 20 - 10
packages/app/src/components/LoginForm.tsx

@@ -9,15 +9,18 @@ import ReactCardFlip from 'react-card-flip';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { LoginErrorCode } from '~/interfaces/errors/login-error';
 import { IErrorV3 } from '~/interfaces/errors/v3-error';
+import { RegistrationMode } from '~/interfaces/registration-mode';
 import { toArrayIfNot } from '~/utils/array-utils';
 
+import { CompleteUserRegistration } from './CompleteUserRegistration';
+
 type LoginFormProps = {
   username?: string,
   name?: string,
   email?: string,
   isRegistrationEnabled: boolean,
   isEmailAuthenticationEnabled: boolean,
-  registrationMode?: string,
+  registrationMode: RegistrationMode,
   registrationWhiteList: string[],
   isPasswordResetEnabled: boolean,
   isLocalStrategySetup: boolean,
@@ -51,7 +54,8 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
   const [registerErrors, setRegisterErrors] = useState<IErrorV3[]>([]);
   // For UserActivation
   const [emailForRegistrationOrder, setEmailForRegistrationOrder] = useState('');
-  const [isSuccessToSendRegistrationOrderEmail, setIsSuccessToSendRegistrationOrderEmail] = useState(false);
+
+  const [isSuccessToRagistration, setIsSuccessToRagistration] = useState(false);
 
 
   useEffect(() => {
@@ -271,7 +275,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
   const handleRegisterFormSubmit = useCallback(async(e, requestPath) => {
     e.preventDefault();
     setEmailForRegistrationOrder('');
-    setIsSuccessToSendRegistrationOrderEmail(false);
+    setIsSuccessToRagistration(false);
 
     const registerForm = {
       username: usernameForRegister,
@@ -282,14 +286,17 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     try {
       const res = await apiv3Post(requestPath, { registerForm });
 
+      setIsSuccessToRagistration(true);
       resetRegisterErrors();
 
       const { redirectTo } = res.data;
-      router.push(redirectTo ?? '/');
+      if (redirectTo != null) {
+        router.push(redirectTo);
+      }
 
       if (isEmailAuthenticationEnabled) {
         setEmailForRegistrationOrder(emailForRegister);
-        setIsSuccessToSendRegistrationOrderEmail(true);
+        return;
       }
     }
     catch (err) {
@@ -318,7 +325,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
 
     return (
       <React.Fragment>
-        {registrationMode === 'Restricted' && (
+        {registrationMode === RegistrationMode.RESTRICTED && (
           <p className="alert alert-warning">
             {t('page_register.notice.restricted')}
             <br />
@@ -346,7 +353,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
         }
 
         {
-          (isEmailAuthenticationEnabled && isSuccessToSendRegistrationOrderEmail) && (
+          (isEmailAuthenticationEnabled && isSuccessToRagistration) && (
             <p className="alert alert-success">
               <span>{t('message.successfully_send_email_auth', { email: emailForRegistrationOrder })}</span>
             </p>
@@ -476,11 +483,14 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
       </React.Fragment>
     );
   }, [
-    handleRegisterFormSubmit, isEmailAuthenticationEnabled, isMailerSetup,
-    isSuccessToSendRegistrationOrderEmail, props.email, props.name, props.username,
-    registerErrors, registrationMode, registrationWhiteList, emailForRegistrationOrder, switchForm, t,
+    t, isEmailAuthenticationEnabled, registrationMode, isMailerSetup, registerErrors, isSuccessToRagistration,
+    emailForRegistrationOrder, props.username, props.name, props.email, registrationWhiteList, switchForm, handleRegisterFormSubmit,
   ]);
 
+  if (registrationMode === RegistrationMode.RESTRICTED && isSuccessToRagistration && !isEmailAuthenticationEnabled) {
+    return <CompleteUserRegistration />;
+  }
+
   return (
     <div className="noLogin-dialog mx-auto" id="noLogin-dialog">
       <div className="row mx-0">

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

@@ -6,7 +6,7 @@ import dynamic from 'next/dynamic';
 import { useRouter } from 'next/router';
 import { DropdownItem } from 'reactstrap';
 
-import { exportAsMarkdown } from '~/client/services/page-operation';
+import { exportAsMarkdown, updateContentWidth } from '~/client/services/page-operation';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiPost } from '~/client/util/apiv1-client';
 import {
@@ -17,7 +17,7 @@ import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/in
 import {
   useCurrentPageId, useCurrentPathname,
   useIsNotFound,
-  useCurrentUser, useIsGuestUser, useIsSharedUser, useShareLinkId, useTemplateTagData,
+  useCurrentUser, useIsGuestUser, useIsSharedUser, useShareLinkId, useTemplateTagData, useIsContainerFluid,
 } from '~/stores/context';
 import { usePageTagsForEditors } from '~/stores/editor';
 import {
@@ -202,6 +202,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: shareLinkId } = useShareLinkId();
+  const { data: isContainerFluid } = useIsContainerFluid();
 
   const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
   const { data: isAbleToShowTagLabel } = useIsAbleToShowTagLabel();
@@ -313,6 +314,11 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
     openDeleteModal([pageWithMeta], { onDeleted: deletedHandler });
   }, [openDeleteModal, reload, router]);
 
+  const switchContentWidthHandler = useCallback(async(pageId: string, value: boolean) => {
+    await updateContentWidth(pageId, value);
+    mutateCurrentPage();
+  }, [mutateCurrentPage]);
+
   const templateMenuItemClickHandler = useCallback(() => {
     setIsPageTempleteModalShown(true);
   }, []);
@@ -356,12 +362,14 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
                     revisionId={revisionId}
                     shareLinkId={shareLinkId}
                     path={path ?? currentPathname} // If the page is empty, "path" is undefined
+                    expandContentWidth={currentPage?.expandContentWidth ?? isContainerFluid}
                     disableSeenUserInfoPopover={isSharedUser}
                     showPageControlDropdown={isAbleToShowPageManagement}
                     additionalMenuItemRenderer={additionalMenuItemsRenderer}
                     onClickDuplicateMenuItem={duplicateItemClickedHandler}
                     onClickRenameMenuItem={renameItemClickedHandler}
                     onClickDeleteMenuItem={deleteItemClickedHandler}
+                    onClickSwitchContentWidth={switchContentWidthHandler}
                   />
                 ) }
               </div>
@@ -402,7 +410,10 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
       </>
     );
   // eslint-disable-next-line max-len
-  }, [isCompactMode, isViewMode, pageId, revisionId, shareLinkId, path, currentPathname, isSharedUser, isAbleToShowPageManagement, duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler, isAbleToShowPageEditorModeManager, isGuestUser, editorMode, isAbleToShowPageAuthors, currentPage, currentUser, isPageTemplateModalShown, isLinkSharingDisabled, templateMenuItemClickHandler, mutateEditorMode]);
+  }, [isCompactMode, isViewMode, pageId, revisionId, shareLinkId, path, currentPathname, isSharedUser, isAbleToShowPageManagement,
+      duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler, isAbleToShowPageEditorModeManager, isGuestUser,
+      editorMode, isAbleToShowPageAuthors, currentPage, currentUser, isPageTemplateModalShown, isLinkSharingDisabled, templateMenuItemClickHandler,
+      mutateEditorMode, switchContentWidthHandler]);
 
 
   const pagePath = isNotFound

+ 1 - 0
packages/app/src/components/Navbar/PageEditorModeManager.jsx

@@ -27,6 +27,7 @@ const PageEditorModeButtonWrapper = React.memo(({
       className={classNames.join(' ')}
       onClick={() => { onClick(targetMode) }}
       id={id}
+      data-testId={`${targetMode}-button`}
     >
       <span className="d-flex flex-column flex-md-row justify-content-center">
         <span className="grw-page-editor-mode-manager-icon mr-md-1">{icon}</span>

+ 23 - 18
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -4,7 +4,7 @@ import { useTranslation } from 'next-i18next';
 import { DropdownItem } from 'reactstrap';
 
 import {
-  toggleBookmark, toggleLike, toggleSubscribe, updateContentWidth,
+  toggleBookmark, toggleLike, toggleSubscribe,
 } from '~/client/services/page-operation';
 import { toastError } from '~/client/util/apiNotification';
 import {
@@ -28,22 +28,19 @@ import SeenUserInfo from '../User/SeenUserInfo';
 
 type WideViewMenuItemProps = AdditionalMenuItemsRendererProps & {
   onClickMenuItem: (newValue: boolean) => void,
+  expandContentWidth?: boolean,
 }
 
 const WideViewMenuItem = (props: WideViewMenuItemProps): JSX.Element => {
   const { t } = useTranslation();
 
   const {
-    pageInfo, onClickMenuItem,
+    onClickMenuItem, expandContentWidth,
   } = props;
 
-  if (!isIPageInfoForEntity(pageInfo)) {
-    return <></>;
-  }
-
   return (
     <DropdownItem
-      onClick={() => onClickMenuItem(!pageInfo.expandContentWidth)}
+      onClick={() => onClickMenuItem(!(expandContentWidth))}
       className="grw-page-control-dropdown-item"
     >
       <div className="custom-control custom-switch ml-1">
@@ -51,7 +48,7 @@ const WideViewMenuItem = (props: WideViewMenuItemProps): JSX.Element => {
           id="switchContentWidth"
           className="custom-control-input"
           type="checkbox"
-          checked={pageInfo.expandContentWidth}
+          checked={expandContentWidth}
           onChange={() => {}}
         />
         <label className="custom-control-label" htmlFor="switchContentWidth">
@@ -72,6 +69,7 @@ type CommonProps = {
   onClickDuplicateMenuItem?: (pageToDuplicate: IPageForPageDuplicateModal) => void,
   onClickRenameMenuItem?: (pageToRename: IPageToRenameWithMeta) => void,
   onClickDeleteMenuItem?: (pageToDelete: IPageToDeleteWithMeta) => void,
+  onClickSwitchContentWidth?: (pageId: string, value: boolean) => void,
 }
 
 type SubNavButtonsSubstanceProps = CommonProps & {
@@ -80,14 +78,15 @@ type SubNavButtonsSubstanceProps = CommonProps & {
   revisionId: string | null,
   path?: string | null,
   pageInfo: IPageInfoForOperation,
+  expandContentWidth?: boolean,
 }
 
 const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element => {
   const {
     pageInfo,
-    pageId, revisionId, path, shareLinkId,
+    pageId, revisionId, path, shareLinkId, expandContentWidth,
     isCompactMode, disableSeenUserInfoPopover, showPageControlDropdown, forceHideMenuItems, additionalMenuItemRenderer,
-    onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
+    onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem, onClickSwitchContentWidth,
   } = props;
 
   const { data: isGuestUser } = useIsGuestUser();
@@ -185,28 +184,30 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
   }, [onClickDeleteMenuItem, pageId, pageInfo, path, revisionId]);
 
   const switchContentWidthClickHandler = useCallback(async(newValue: boolean) => {
-    if (isGuestUser == null || isGuestUser) {
+    if (onClickSwitchContentWidth == null || isGuestUser == null || isGuestUser) {
       return;
     }
     if (!isIPageInfoForEntity(pageInfo)) {
       return;
     }
     try {
-      await updateContentWidth(pageId, newValue);
-      mutatePageInfo();
+      onClickSwitchContentWidth(pageId, newValue);
     }
     catch (err) {
       toastError(err);
     }
-  }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
+  }, [isGuestUser, onClickSwitchContentWidth, pageId, pageInfo]);
 
   const additionalMenuItemOnTopRenderer = useMemo(() => {
     if (!isIPageInfoForEntity(pageInfo)) {
       return undefined;
     }
-    const wideviewMenuItemRenderer = (props: WideViewMenuItemProps) => <WideViewMenuItem {...props} onClickMenuItem={switchContentWidthClickHandler} />;
+    const wideviewMenuItemRenderer = (props: WideViewMenuItemProps) => {
+
+      return <WideViewMenuItem {...props} onClickMenuItem={switchContentWidthClickHandler} expandContentWidth={expandContentWidth} />;
+    };
     return wideviewMenuItemRenderer;
-  }, [pageInfo, switchContentWidthClickHandler]);
+  }, [pageInfo, switchContentWidthClickHandler, expandContentWidth]);
 
   if (!isIPageInfoForOperation(pageInfo)) {
     return <></>;
@@ -274,12 +275,14 @@ export type SubNavButtonsProps = CommonProps & {
   pageId: string,
   shareLinkId?: string | null,
   revisionId?: string | null,
-  path?: string | null
+  path?: string | null,
+  expandContentWidth?: boolean,
 };
 
 export const SubNavButtons = (props: SubNavButtonsProps): JSX.Element => {
   const {
-    pageId, revisionId, path, shareLinkId, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
+    pageId, revisionId, path, shareLinkId, expandContentWidth,
+    onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem, onClickSwitchContentWidth,
   } = props;
 
   const { data: pageInfo, error } = useSWRxPageInfo(pageId ?? null, shareLinkId);
@@ -302,6 +305,8 @@ export const SubNavButtons = (props: SubNavButtonsProps): JSX.Element => {
       onClickDuplicateMenuItem={onClickDuplicateMenuItem}
       onClickRenameMenuItem={onClickRenameMenuItem}
       onClickDeleteMenuItem={onClickDeleteMenuItem}
+      onClickSwitchContentWidth={onClickSwitchContentWidth}
+      expandContentWidth={expandContentWidth}
     />
   );
 };

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

@@ -229,7 +229,7 @@ const Editor: ForwardRefRenderFunction<IEditorMethods, EditorPropsType> = (props
 
   const renderNavbar = useCallback(() => {
     return (
-      <div className="m-0 navbar navbar-default navbar-editor" style={{ minHeight: 'unset' }}>
+      <div className="m-0 navbar navbar-default navbar-editor" data-testId="navbar-editor" style={{ minHeight: 'unset' }}>
         <ul className="pl-2 nav nav-navbar">
           { (editorSubstance()?.getNavbarItems() ?? []).map((item, idx) => {
             // eslint-disable-next-line react/no-array-index-key

+ 14 - 3
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -9,12 +9,12 @@ import { animateScroll } from 'react-scroll';
 import { DropdownItem } from 'reactstrap';
 
 
-import { exportAsMarkdown } from '~/client/services/page-operation';
+import { exportAsMarkdown, updateContentWidth } from '~/client/services/page-operation';
 import { toastSuccess } from '~/client/util/apiNotification';
 import { IPageToDeleteWithMeta, IPageToRenameWithMeta } from '~/interfaces/page';
 import { IPageWithSearchMeta } from '~/interfaces/search';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
-import { useCurrentUser } from '~/stores/context';
+import { useCurrentUser, useIsContainerFluid } from '~/stores/context';
 import {
   usePageDuplicateModal, usePageRenameModal, usePageDeleteModal,
 } from '~/stores/modal';
@@ -156,6 +156,9 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
   const { open: openDeleteModal } = usePageDeleteModal();
   const { data: rendererOptions } = useSearchResultOptions(pageWithMeta.data.path, highlightKeywords);
   const { data: currentUser } = useCurrentUser();
+  const { data: isContainerFluid } = useIsContainerFluid();
+
+  const [isExpandContentWidth, setIsExpandContentWidth] = useState(page.expandContentWidth);
 
   const duplicateItemClickedHandler = useCallback(async(pageToDuplicate) => {
     // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -201,6 +204,11 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
     openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
   }, [onDeletedHandler, openDeleteModal]);
 
+  const switchContentWidthHandler = useCallback(async(pageId: string, value: boolean) => {
+    await updateContentWidth(pageId, value);
+    setIsExpandContentWidth(value);
+  }, []);
+
   const RightComponent = useCallback(() => {
     if (page == null) {
       return <></>;
@@ -214,6 +222,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
           pageId={page._id}
           revisionId={revisionId}
           path={page.path}
+          expandContentWidth={isExpandContentWidth ?? isContainerFluid}
           showPageControlDropdown={showPageControlDropdown}
           forceHideMenuItems={forceHideMenuItems}
           additionalMenuItemRenderer={props => <AdditionalMenuItems {...props} pageId={page._id} revisionId={revisionId} />}
@@ -221,10 +230,12 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
           onClickDuplicateMenuItem={duplicateItemClickedHandler}
           onClickRenameMenuItem={renameItemClickedHandler}
           onClickDeleteMenuItem={deleteItemClickedHandler}
+          onClickSwitchContentWidth={switchContentWidthHandler}
         />
       </div>
     );
-  }, [page, showPageControlDropdown, forceHideMenuItems, duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler]);
+  }, [page, isExpandContentWidth, showPageControlDropdown, forceHideMenuItems, isContainerFluid,
+      duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler, switchContentWidthHandler]);
 
   // return if page or growiRenderer is null
   if (page == null || rendererOptions == null) return <></>;

+ 7 - 0
packages/app/src/interfaces/registration-mode.ts

@@ -0,0 +1,7 @@
+export const RegistrationMode = {
+  OPEN: 'Open',
+  RESTRICTED: 'Restricted',
+  CLOSED: 'Closed',
+} as const;
+
+export type RegistrationMode = typeof RegistrationMode[keyof typeof RegistrationMode];

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

@@ -65,7 +65,7 @@ import {
   useIsAclEnabled, useIsSearchPage,
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useCurrentPageId, useCurrentPathname,
   useIsSlackConfigured, useRendererConfig, useEditingMarkdown,
-  useEditorConfig, useIsAllReplyShown, useIsUploadableFile, useIsUploadableImage, useCustomizedLogoSrc,
+  useEditorConfig, useIsAllReplyShown, useIsUploadableFile, useIsUploadableImage, useCustomizedLogoSrc, useIsContainerFluid,
 } from '../stores/context';
 
 import {
@@ -197,6 +197,7 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
 
   // page
   useIsLatestRevision(props.isLatestRevision);
+  useIsContainerFluid(props.isContainerFluid);
   // useOwnerOfCurrentPage(props.pageUser != null ? JSON.parse(props.pageUser) : null);
   useIsForbidden(props.isForbidden);
   useIsNotFound(props.isNotFound);
@@ -241,10 +242,9 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   // useIsNotCreatable(props.isForbidden || !isCreatablePage(pagePath)); // TODO: need to include props.isIdentical
   useCurrentPathname(props.currentPathname);
 
-  useSWRxCurrentPage(undefined, pageWithMeta?.data ?? null); // store initial data
+  const { data: currentPage } = useSWRxCurrentPage(undefined, pageWithMeta?.data ?? null); // store initial data
   useEditingMarkdown(pageWithMeta?.data.revision?.body ?? '');
 
-  const { data: dataPageInfo } = useSWRxPageInfo(pageId);
   const { data: grantData } = useSWRxIsGrantNormalized(pageId);
   const { mutate: mutateSelectedGrant } = useSelectedGrant();
 
@@ -274,9 +274,9 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
 
   const isTopPagePath = isTopPage(pageWithMeta?.data.path ?? '');
 
-  const isContainerFluidEachPage = dataPageInfo == null || !('expandContentWidth' in dataPageInfo)
+  const isContainerFluidEachPage = currentPage == null || !('expandContentWidth' in currentPage)
     ? null
-    : dataPageInfo.expandContentWidth;
+    : currentPage.expandContentWidth;
   const isContainerFluidDefault = props.isContainerFluid;
   const isContainerFluid = isContainerFluidEachPage ?? isContainerFluidDefault;
 

+ 5 - 1
packages/app/src/pages/_search.page.tsx

@@ -12,7 +12,7 @@ import type { IUser, IUserHasId } from '~/interfaces/user';
 import type { IUserUISettings } from '~/interfaces/user-ui-settings';
 import type { UserUISettingsModel } from '~/server/models/user-ui-settings';
 import {
-  useCsrfToken, useCurrentUser, useIsSearchPage, useIsSearchScopeChildrenAsDefault,
+  useCsrfToken, useCurrentUser, useIsContainerFluid, useIsSearchPage, useIsSearchScopeChildrenAsDefault,
   useIsSearchServiceConfigured, useIsSearchServiceReachable, useRendererConfig, useShowPageLimitationL,
 } from '~/stores/context';
 import {
@@ -46,6 +46,8 @@ type Props = CommonProps & {
   // search limit
   showPageLimitationL: number
 
+  isContainerFluid: boolean,
+
 };
 
 const SearchResultPage: NextPage<Props> = (props: Props) => {
@@ -73,6 +75,7 @@ const SearchResultPage: NextPage<Props> = (props: Props) => {
   useRendererConfig(props.rendererConfig);
 
   useShowPageLimitationL(props.showPageLimitationL);
+  useIsContainerFluid(props.isContainerFluid);
 
   const PutbackPageModal = (): JSX.Element => {
     const PutbackPageModal = dynamic(() => import('../components/PutbackPageModal'), { ssr: false });
@@ -125,6 +128,7 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   props.isSearchServiceConfigured = searchService.isConfigured;
   props.isSearchServiceReachable = searchService.isReachable;
   props.isSearchScopeChildrenAsDefault = configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
+  props.isContainerFluid = configManager.getConfig('crowi', 'customize:isContainerFluid');
 
   props.sidebarConfig = {
     isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),

+ 4 - 3
packages/app/src/pages/login.page.tsx

@@ -1,6 +1,5 @@
 import React from 'react';
 
-
 import {
   NextPage, GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
@@ -9,19 +8,19 @@ import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import { NoLoginLayout } from '~/components/Layout/NoLoginLayout';
 import { LoginForm } from '~/components/LoginForm';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
+import type { RegistrationMode } from '~/interfaces/registration-mode';
 
 import {
   useCsrfToken,
   useCurrentPathname,
 } from '../stores/context';
 
-
 import {
   CommonProps, getServerSideCommonProps, useCustomTitle, getNextI18NextConfig,
 } from './utils/commons';
 
 type Props = CommonProps & {
-
+  registrationMode: RegistrationMode,
   pageWithMetaStr: string,
   isMailerSetup: boolean,
   enabledStrategies: unknown,
@@ -55,6 +54,7 @@ const LoginPage: NextPage<Props> = (props: Props) => {
         registrationWhiteList={props.registrationWhiteList}
         isPasswordResetEnabled={true}
         isMailerSetup={props.isMailerSetup}
+        registrationMode={props.registrationMode}
       />
     </NoLoginLayout>
   );
@@ -106,6 +106,7 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
   props.isLdapSetupFailed = configManager.getConfig('crowi', 'security:passport-ldap:isEnabled') && !props.isLdapStrategySetup;
   props.registrationWhiteList = configManager.getConfig('crowi', 'security:registrationWhiteList');
   props.isEmailAuthenticationEnabled = configManager.getConfig('crowi', 'security:passport-local:isEmailAuthenticationEnabled');
+  props.registrationMode = configManager.getConfig('crowi', 'security:registrationMode');
 }
 
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {

+ 4 - 0
packages/app/src/pages/user-activation.page.tsx

@@ -5,6 +5,7 @@ import CompleteUserRegistrationForm from '~/components/CompleteUserRegistrationF
 import { NoLoginLayout } from '~/components/Layout/NoLoginLayout';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { UserActivationErrorCode } from '~/interfaces/errors/user-activation';
+import type { RegistrationMode } from '~/interfaces/registration-mode';
 import { IUserRegistrationOrder } from '~/server/models/user-registration-order';
 
 import {
@@ -15,6 +16,7 @@ type Props = CommonProps & {
   token: string
   email: string
   errorCode?: UserActivationErrorCode
+  registrationMode: RegistrationMode
   isEmailAuthenticationEnabled: boolean
 }
 
@@ -25,6 +27,7 @@ const UserActivationPage: NextPage<Props> = (props: Props) => {
         token={props.token}
         email={props.email}
         errorCode={props.errorCode}
+        registrationMode={props.registrationMode}
         isEmailAuthenticationEnabled={props.isEmailAuthenticationEnabled}
       />
     </NoLoginLayout>
@@ -64,6 +67,7 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
     props.errorCode = context.query.errorCode as UserActivationErrorCode;
   }
 
+  props.registrationMode = req.crowi.configManager.getConfig('crowi', 'security:registrationMode');
   props.isEmailAuthenticationEnabled = req.crowi.configManager.getConfig('crowi', 'security:passport-local:isEmailAuthenticationEnabled');
 
   await injectNextI18NextConfigurations(context, props, ['translation']);

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

@@ -97,6 +97,7 @@ module.exports = (crowi, app, isInstalled) => {
   router.get('/check-username', user.api.checkUsername);
 
   router.post('/complete-registration',
+    addActivity,
     injectUserRegistrationOrderByTokenMiddleware,
     userActivation.completeRegistrationRules(),
     userActivation.validateCompleteRegistration,

+ 30 - 1
packages/app/src/server/routes/apiv3/user-activation.ts

@@ -4,6 +4,7 @@ import { ErrorV3 } from '@growi/core';
 import { format, subSeconds } from 'date-fns';
 import { body, validationResult } from 'express-validator';
 
+import { SupportedAction } from '~/interfaces/activity';
 import UserRegistrationOrder from '~/server/models/user-registration-order';
 import loggerFactory from '~/utils/logger';
 
@@ -64,6 +65,7 @@ async function sendEmailToAllAdmins(userData, admins, appTitle, mailService, tem
 
 export const completeRegistrationAction = (crowi) => {
   const User = crowi.model('User');
+  const activityEvent = crowi.event('activity');
   const {
     configManager,
     aclService,
@@ -127,6 +129,9 @@ export const completeRegistrationAction = (crowi) => {
           return res.apiv3Err(new ErrorV3(errorMessage, 'registration-failed'), 403);
         }
 
+        const parameters = { action: SupportedAction.ACTION_USER_REGISTRATION_SUCCESS };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
         userRegistrationOrder.revokeOneTimeToken();
 
         if (configManager.getConfig('crowi', 'security:registrationMode') === aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED) {
@@ -145,9 +150,28 @@ export const completeRegistrationAction = (crowi) => {
           else {
             logger.warn('E-mail Settings must be set up.');
           }
+
+          return res.apiv3({});
         }
 
-        res.apiv3({ status: 'ok' });
+        req.login(userData, (err) => {
+          if (err) {
+            logger.debug(err);
+          }
+          else {
+            // update lastLoginAt
+            userData.updateLastLoginAt(new Date(), (err) => {
+              if (err) {
+                logger.error(`updateLastLoginAt dumps error: ${err}`);
+              }
+            });
+          }
+
+          // userData.password cann't be empty but, prepare redirect because password property in User Model is optional
+          // https://github.com/weseek/growi/pull/6670
+          const redirectTo = userData.password != null ? '/' : '/me#password';
+          return res.apiv3({ redirectTo });
+        });
       });
     });
   };
@@ -222,12 +246,17 @@ export const registerAction = (crowi) => {
     const registerForm = req.body.registerForm || {};
     const email = registerForm.email;
     const isRegisterableEmail = await User.isRegisterableEmail(email);
+    const isEmailValid = await User.isEmailValid(email);
 
     if (!isRegisterableEmail) {
       req.body.registerForm.email = email;
       return res.apiv3Err(['message.email_address_is_already_registered'], 400);
     }
 
+    if (!isEmailValid) {
+      return res.apiv3Err(['message.email_address_could_not_be_used'], 400);
+    }
+
     try {
       await makeRegistrationEmailToken(email, crowi);
     }

+ 44 - 39
packages/app/src/server/routes/login.js

@@ -16,35 +16,6 @@ module.exports = function(crowi, app) {
 
   const actions = {};
 
-  const registerSuccessHandler = function(req, res, userData) {
-    req.login(userData, (err) => {
-      if (err) {
-        logger.debug(err);
-      }
-      else {
-        // update lastLoginAt
-        userData.updateLastLoginAt(new Date(), (err) => {
-          if (err) {
-            logger.error(`updateLastLoginAt dumps error: ${err}`);
-          }
-        });
-      }
-
-
-      // userData.password cann't be empty but, prepare redirect because password property in User Model is optional
-      // https://github.com/weseek/growi/pull/6670
-      const redirectTo = userData.password ? req.session.redirectTo : '/me#password';
-
-      // remove session.redirectTo
-      delete req.session.redirectTo;
-
-      const parameters = { action: SupportedAction.ACTION_USER_REGISTRATION_SUCCESS };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-
-      return res.apiv3({ redirectTo });
-    });
-  };
-
   async function sendEmailToAllAdmins(userData) {
     // send mails to all admin users (derived from crowi) -- 2020.06.18 Yuki Takei
     const admins = await User.findAdmins();
@@ -71,6 +42,46 @@ module.exports = function(crowi, app) {
       .forEach(result => logger.error(result.reason));
   }
 
+  const registerSuccessHandler = async function(req, res, userData, registrationMode) {
+    const parameters = { action: SupportedAction.ACTION_USER_REGISTRATION_SUCCESS };
+    activityEvent.emit('update', res.locals.activity._id, parameters);
+
+    if (registrationMode === aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED) {
+      await sendEmailToAllAdmins(userData);
+      return res.apiv3({});
+    }
+
+    req.login(userData, (err) => {
+      if (err) {
+        logger.debug(err);
+      }
+      else {
+        // update lastLoginAt
+        userData.updateLastLoginAt(new Date(), (err) => {
+          if (err) {
+            logger.error(`updateLastLoginAt dumps error: ${err}`);
+          }
+        });
+      }
+
+      let redirectTo;
+      if (userData.password == null) {
+        // userData.password cann't be empty but, prepare redirect because password property in User Model is optional
+        // https://github.com/weseek/growi/pull/6670
+        redirectTo = '/me#password';
+      }
+      else if (req.session.redirectTo != null) {
+        redirectTo = req.session.redirectTo;
+        delete req.session.redirectTo;
+      }
+      else {
+        redirectTo = '/';
+      }
+
+      return res.apiv3({ redirectTo });
+    });
+  };
+
   actions.error = function(req, res) {
     const reason = req.params.reason;
 
@@ -133,14 +144,14 @@ module.exports = function(crowi, app) {
     User.isRegisterable(email, username, (isRegisterable, errOn) => {
       const errors = [];
       if (!User.isEmailValid(email)) {
-        errors.push('email_address_could_not_be_used');
+        errors.push('message.email_address_could_not_be_used');
       }
       if (!isRegisterable) {
         if (!errOn.username) {
-          errors.push('user_id_is_not_available');
+          errors.push('message.user_id_is_not_available');
         }
         if (!errOn.email) {
-          errors.push('email_address_is_already_registered');
+          errors.push('message.email_address_is_already_registered');
         }
       }
       if (errors.length > 0) {
@@ -166,13 +177,7 @@ module.exports = function(crowi, app) {
           }
           return res.apiv3Err(errors, 405);
         }
-
-        if (registrationMode === aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED) {
-          // send mail asynchronous
-          sendEmailToAllAdmins(userData);
-        }
-
-        return registerSuccessHandler(req, res, userData);
+        return registerSuccessHandler(req, res, userData, registrationMode);
       });
     });
   };

+ 0 - 7
packages/app/src/server/service/page.ts

@@ -2280,7 +2280,6 @@ class PageService {
 
     const likers = page.liker.slice(0, 15) as Ref<IUserHasId>[];
     const seenUsers = page.seenUsers.slice(0, 15) as Ref<IUserHasId>[];
-    const expandContentWidth = page.expandContentWidth ?? this.crowi.configManager.getConfig('crowi', 'customize:isContainerFluid');
 
     return {
       isV5Compatible: isTopPage(page.path) || page.parent != null,
@@ -2294,7 +2293,6 @@ class PageService {
       isAbleToDeleteCompletely: false,
       isRevertible: isTrashPage(page.path),
       contentAge: page.getContentAge(),
-      expandContentWidth,
     };
 
   }
@@ -3464,8 +3462,6 @@ class PageService {
   async create(path: string, body: string, user, options: PageCreateOptions = {}): Promise<PageDocument> {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
-    const expandContentWidth = this.crowi.configManager.getConfig('crowi', 'customize:isContainerFluid');
-
     // Switch method
     const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
     if (!isV5Compatible) {
@@ -3512,9 +3508,6 @@ class PageService {
       const parent = await this.getParentAndFillAncestorsByUser(user, path);
       page.parent = parent._id;
     }
-    if (expandContentWidth != null) {
-      page.expandContentWidth = expandContentWidth;
-    }
     // Save
     let savedPage = await page.save();
 

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

@@ -221,6 +221,10 @@ export const useGrowiAppIdForGrowiCloud = (initialData?: number): SWRResponse<nu
   return useStaticSWR('growiAppIdForGrowiCloud', initialData);
 };
 
+export const useIsContainerFluid = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useStaticSWR('isContainerFluid', initialData);
+};
+
 /** **********************************************************
  *                     Computed contexts
  *********************************************************** */

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

@@ -40,8 +40,13 @@ context('Access to page', () => {
   });
 
   it('/Sandbox with edit is successfully loaded', () => {
-    cy.visit('/Sandbox#edit');
-    cy.screenshot(`${ssPrefix}-sandbox-edit-page`);
+    cy.visit('/Sandbox');
+    cy.get('.grw-skelton', { timeout: 30000 }).should('not.exist');
+    cy.get('#grw-subnav-container', { timeout: 30000 }).should('be.visible').within(()=>{
+      cy.getByTestid('editor-button', { timeout: 30000 }).should('be.visible').click();
+    })
+    cy.getByTestid('navbar-editor', { timeout: 30000 }).should('be.visible');
+    cy.screenshot(`${ssPrefix}-Sandbox-edit-page`);
   })
 
   it('/user/admin is successfully loaded', () => {

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

@@ -304,7 +304,8 @@ context('Tag Oprations', () =>{
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
-    // cy.get('#wiki').should('be.visible');
+    // eslint-disable-next-line cypress/no-unnecessary-waiting
+    cy.wait(300);
     cy.screenshot(`${ssPrefix}1-click-tag-name`, {capture: 'viewport'});
 
     cy.getByTestid('search-result-list').within(() => {

+ 9 - 6
packages/app/test/cypress/integration/30-search/search.spec.ts

@@ -128,24 +128,27 @@ context('Search all pages', () => {
 
     // Duplicate page
     cy.getByTestid('open-page-duplicate-modal-btn').first().click({force: true});
-    cy.getByTestid('page-duplicate-modal').should('be.visible');
-    cy.screenshot(`${ssPrefix}6-duplicate-page`, {capture: 'viewport'});
+    cy.getByTestid('page-duplicate-modal').should('be.visible').within(() => {
+      cy.screenshot(`${ssPrefix}6-duplicate-page`);
+    });
 
     // Close Modal
     cy.get('body').type('{esc}');
 
     // Move / Rename Page
     cy.getByTestid('open-page-move-rename-modal-btn').first().click({force: true});
-    cy.getByTestid('page-rename-modal').should('be.visible');
-    cy.screenshot(`${ssPrefix}7-move-rename-page`, {capture: 'viewport'});
+    cy.getByTestid('page-rename-modal').should('be.visible').within(() => {
+      cy.screenshot(`${ssPrefix}7-move-rename-page`);
+    });
 
     // Close Modal
     cy.get('body').type('{esc}');
 
     // Delete page
     cy.getByTestid('open-page-delete-modal-btn').first().click({ force: true});
-    cy.getByTestid('page-delete-modal').should('be.visible');
-    cy.screenshot(`${ssPrefix}8-delete-page`, {capture: 'viewport'});
+    cy.getByTestid('page-delete-modal').should('be.visible').within(() => {
+      cy.screenshot(`${ssPrefix}8-delete-page`);
+    });
   });
 
   it(`Search all pages by tag is successfully loaded `, () => {

+ 0 - 1
packages/core/src/interfaces/page.ts

@@ -74,7 +74,6 @@ export type IPageInfoForEntity = IPageInfo & {
   sumOfSeenUsers: number,
   seenUserIds: string[],
   contentAge: number,
-  expandContentWidth?: boolean,
 }
 
 export type IPageInfoForOperation = IPageInfoForEntity & {