Răsfoiți Sursa

Merge branch 'feat/duplicate-with-subordinate-page' into imprv/duplicate-with-subordinate-gw3708

白石誠 5 ani în urmă
părinte
comite
296118da1a
34 a modificat fișierele cu 408 adăugiri și 207 ștergeri
  1. 5 0
      CHANGES.md
  2. 1 1
      README.md
  3. 3 0
      resource/locales/en_US/translation.json
  4. 3 0
      resource/locales/ja_JP/translation.json
  5. 3 0
      resource/locales/zh_CN/translation.json
  6. 5 1
      src/client/js/app.jsx
  7. 38 0
      src/client/js/components/Admin/Common/LabeledProgressBar.jsx
  8. 0 45
      src/client/js/components/Admin/Common/ProgressBar.jsx
  9. 3 2
      src/client/js/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx
  10. 2 2
      src/client/js/components/Admin/ExportArchiveData/SelectCollectionsModal.jsx
  11. 3 3
      src/client/js/components/Admin/ExportArchiveDataPage.jsx
  12. 31 15
      src/client/js/components/Admin/Security/ShareLinkSetting.jsx
  13. 59 0
      src/client/js/components/DuplicatedPathsTable.jsx
  14. 42 0
      src/client/js/components/Navbar/AuthorInfo.jsx
  15. 7 12
      src/client/js/components/Navbar/GrowiSubNavigation.jsx
  16. 0 37
      src/client/js/components/Navbar/PageCreator.jsx
  17. 0 41
      src/client/js/components/Navbar/RevisionAuthor.jsx
  18. 2 2
      src/client/js/components/Page/TrashPageAlert.jsx
  19. 1 1
      src/client/js/components/PageAttachment.jsx
  20. 7 11
      src/client/js/components/PageAttachment/Attachment.jsx
  21. 0 3
      src/client/js/components/PageAttachment/PageAttachmentList.jsx
  22. 6 2
      src/client/js/components/PageComment/Comment.jsx
  23. 50 12
      src/client/js/components/PageRenameModal.jsx
  24. 2 1
      src/client/js/services/CommentContainer.js
  25. 15 3
      src/client/js/services/PageContainer.js
  26. 1 1
      src/client/styles/scss/theme/_apply-colors-light.scss
  27. 16 1
      src/client/styles/scss/theme/_apply-colors.scss
  28. 6 5
      src/client/styles/scss/theme/default.scss
  29. 18 0
      src/server/models/page.js
  30. 62 0
      src/server/routes/apiv3/page.js
  31. 5 0
      src/server/routes/attachment.js
  32. 7 2
      src/server/routes/page.js
  33. 2 2
      src/server/service/search-delegator/elasticsearch.js
  34. 3 2
      src/server/views/widget/page_content.html

+ 5 - 0
CHANGES.md

@@ -12,6 +12,11 @@
 * Improvement: Basic layout of page
 * Support: Support MongoDB 4.0, 4.2 and 4.4
 
+## v4.1.8
+
+* Improvement: Rebuilding progress bar colors for Full Text Search Management
+* Improvement: Support operations on page data with a null value for author
+
 ## v4.1.7
 
 * Improvement: Fire global notification when a new page is created by uploading file

+ 1 - 1
README.md

@@ -95,7 +95,7 @@ Development
 - Node.js v12.x or v14.x
 - npm 6.x
 - yarn
-- MongoDB 3.x
+- MongoDB 4.x
 
 See [confirmed versions](https://docs.growi.org/en/dev/startup/dev-env.html#set-up-node-js-environment).
 

+ 3 - 0
resource/locales/en_US/translation.json

@@ -145,6 +145,7 @@
   "Recent Changes": "Recent Changes",
   "original_path":"Original path",
   "new_path":"New path",
+  "duplicated_path":"duplicated_path",
   "personal_dropdown": {
     "home": "Home",
     "settings": "Settings",
@@ -307,6 +308,8 @@
       "Move/Rename page": "Move/Rename page",
       "New page name": "New page name",
       "Fail to get subordinated pages": "Fail to get subordinated pages",
+      "Fail to get exist path": "Fail to get exist path",
+      "Rename without exist path": "Rename without exist path",
       "Current page name": "Current page name",
       "Recursively": "Recursively",
       "Do not update metadata": "Do not update metadata",

+ 3 - 0
resource/locales/ja_JP/translation.json

@@ -148,6 +148,7 @@
   "Recent Changes": "最新の変更",
   "original_path":"元のパス",
   "new_path":"新しいパス",
+  "duplicated_path":"重複したパス",
   "personal_dropdown": {
     "home": "ホーム",
     "settings": "設定",
@@ -309,6 +310,8 @@
       "Move/Rename page": "ページを移動/名前変更する",
       "New page name": "移動先のページ名",
       "Fail to get subordinated pages": "配下ページの取得に失敗しました",
+      "Fail to get exist path": "存在するパスの取得に失敗しました",
+      "Rename without exist path": "存在するパス以外を名前変更する",
       "Current page name": "現在のページ名",
       "Recursively": "再帰的に移動/名前変更",
       "Do not update metadata": "メタデータを更新しない",

+ 3 - 0
resource/locales/zh_CN/translation.json

@@ -153,6 +153,7 @@
   "Recent Changes": "最新修改",
   "original_path":"Original path",
   "new_path":"New path",
+  "duplicated_path":"duplicated_path",
 	"form_validation": {
 		"error_message": "有些值不正确",
 		"required": "%s 是必需的",
@@ -287,6 +288,8 @@
 			"Move/Rename page": "页面 移动/重命名",
       "New page name": "新建页面名称",
       "Fail to get subordinated pages": "Fail to get subordinated pages",
+      "Fail to get exist path": "Fail to get exist path",
+      "Rename without exist path": "Rename without exist path",
 			"Current page name": "当前页面名称",
 			"Recursively": "递归地",
 			"Do not update metadata": "不更新元数据",

+ 5 - 1
src/client/js/app.jsx

@@ -93,10 +93,14 @@ if (pageContainer.state.pageId != null) {
     'seen-user-list': <SeenUserList />,
     'liker-list': <LikerList />,
 
-    'user-created-list': <RecentCreated userId={pageContainer.state.creator._id} />,
     'user-draft-list': <MyDraftList />,
   });
 }
+if (pageContainer.state.creator != null) {
+  Object.assign(componentMappings, {
+    'user-created-list': <RecentCreated userId={pageContainer.state.creator._id} />,
+  });
+}
 if (pageContainer.state.path != null) {
   Object.assign(componentMappings, {
     // eslint-disable-next-line quote-props

+ 38 - 0
src/client/js/components/Admin/Common/LabeledProgressBar.jsx

@@ -0,0 +1,38 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { Progress } from 'reactstrap';
+
+const LabeledProgressBar = (props) => {
+
+  const {
+    header, currentCount, totalCount, errorsCount, isInProgress,
+  } = props;
+
+  const progressingColor = isInProgress ? 'info' : 'success';
+
+  return (
+    <>
+      <h6 className="my-1">
+        {header}
+        <div className="float-right">{currentCount} / {totalCount}</div>
+      </h6>
+      <Progress multi>
+        <Progress bar max={totalCount} color={progressingColor} striped={isInProgress} animated={isInProgress} value={currentCount} />
+        <Progress bar max={totalCount} color="danger" striped={isInProgress} animated={isInProgress} value={errorsCount} />
+      </Progress>
+    </>
+  );
+
+};
+
+LabeledProgressBar.propTypes = {
+  header: PropTypes.string.isRequired,
+  currentCount: PropTypes.number.isRequired,
+  totalCount: PropTypes.number.isRequired,
+  errorsCount: PropTypes.number,
+  isInProgress: PropTypes.bool,
+};
+
+export default withTranslation()(LabeledProgressBar);

+ 0 - 45
src/client/js/components/Admin/Common/ProgressBar.jsx

@@ -1,45 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-class ProgressBar extends React.Component {
-
-
-  render() {
-    const {
-      header, currentCount, totalCount, isInProgress,
-    } = this.props;
-
-    const percentage = currentCount / totalCount * 100;
-    const isActive = (isInProgress != null)
-      ? isInProgress //                         apply props.isInProgress if set
-      : (currentCount !== totalCount); //       otherwise, set true when currentCount does not equal totalCount
-
-    return (
-      <>
-        <h6 className="my-1">
-          {header}
-          <div className="float-right">{currentCount} / {totalCount}</div>
-        </h6>
-        <div className="progress">
-          <div
-            className={`progress-bar ${isActive ? 'bg-info progress-bar-striped active' : 'bg-success'}`}
-            style={{ width: `${percentage}%` }}
-          >
-            <span className="sr-only">{percentage.toFixed(0)}% Complete</span>
-          </div>
-        </div>
-      </>
-    );
-  }
-
-}
-
-ProgressBar.propTypes = {
-  header: PropTypes.string.isRequired,
-  currentCount: PropTypes.number.isRequired,
-  totalCount: PropTypes.number.isRequired,
-  isInProgress: PropTypes.bool,
-};
-
-export default withTranslation()(ProgressBar);

+ 3 - 2
src/client/js/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx

@@ -6,7 +6,7 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppContainer from '../../../services/AppContainer';
 import AdminSocketIoContainer from '../../../services/AdminSocketIoContainer';
 
-import ProgressBar from '../Common/ProgressBar';
+import LabeledProgressBar from '../Common/LabeledProgressBar';
 
 class RebuildIndexControls extends React.Component {
 
@@ -70,9 +70,10 @@ class RebuildIndexControls extends React.Component {
     const header = isRebuildingCompleted ? getCompletedLabel() : getSkipLabel();
 
     return (
-      <ProgressBar
+      <LabeledProgressBar
         header={header}
         currentCount={current}
+        errorsCount={skip}
         totalCount={total}
       />
     );

+ 2 - 2
src/client/js/components/Admin/ExportArchiveData/SelectCollectionsModal.jsx

@@ -219,8 +219,8 @@ class SelectCollectionsModal extends React.Component {
           </ModalBody>
 
           <ModalFooter>
-            <button type="button" className="btn btn-sm btn-outline-secondary" onClick={this.props.onClose}>{t('export_management.cancel')}</button>
-            <button type="submit" className="btn btn-sm btn-primary" disabled={!this.validateForm()}>{t('export_management.export')}</button>
+            <button type="button" className="btn btn-sm btn-outline-secondary" onClick={this.props.onClose}>{t('admin:export_management.cancel')}</button>
+            <button type="submit" className="btn btn-sm btn-primary" disabled={!this.validateForm()}>{t('admin:export_management.export')}</button>
           </ModalFooter>
         </form>
       </Modal>

+ 3 - 3
src/client/js/components/Admin/ExportArchiveDataPage.jsx

@@ -10,7 +10,7 @@ import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '../../services/AppContainer';
 import AdminSocketIoContainer from '../../services/AdminSocketIoContainer';
 
-import ProgressBar from './Common/ProgressBar';
+import LabeledProgressBar from './Common/LabeledProgressBar';
 
 import SelectCollectionsModal from './ExportArchiveData/SelectCollectionsModal';
 import ArchiveFilesTable from './ExportArchiveData/ArchiveFilesTable';
@@ -169,7 +169,7 @@ class ExportArchiveDataPage extends React.Component {
       const { collectionName, currentCount, totalCount } = progressData;
       return (
         <div className="col-md-6" key={collectionName}>
-          <ProgressBar
+          <LabeledProgressBar
             header={collectionName}
             currentCount={currentCount}
             totalCount={totalCount}
@@ -192,7 +192,7 @@ class ExportArchiveDataPage extends React.Component {
     return (
       <div className="row px-3">
         <div className="col-md-12" key="progressBarForZipping">
-          <ProgressBar
+          <LabeledProgressBar
             header="Zip Files"
             currentCount={1}
             totalCount={1}

+ 31 - 15
src/client/js/components/Admin/Security/ShareLinkSetting.jsx

@@ -13,6 +13,30 @@ import AdminGeneralSecurityContainer from '../../../services/AdminGeneralSecurit
 import DeleteAllShareLinksModal from './DeleteAllShareLinksModal';
 import ShareLinkList from '../../ShareLink/ShareLinkList';
 
+
+const Pager = (props) => {
+  if (props.links.length === 0) {
+    return null;
+  }
+  return (
+    <PaginationWrapper
+      activePage={props.activePage}
+      changePage={props.handlePage}
+      totalItemsCount={props.totalLinks}
+      pagingLimit={props.limit}
+      align="right"
+    />
+  );
+};
+
+Pager.propTypes = {
+  links: PropTypes.array.isRequired,
+  activePage: PropTypes.number.isRequired,
+  handlePage: PropTypes.func.isRequired,
+  totalLinks: PropTypes.number.isRequired,
+  limit: PropTypes.number.isRequired,
+};
+
 class ShareLinkSetting extends React.Component {
 
   constructor() {
@@ -87,20 +111,6 @@ class ShareLinkSetting extends React.Component {
       shareLinks, shareLinksActivePage, totalshareLinks, shareLinksPagingLimit,
     } = adminGeneralSecurityContainer.state;
 
-    function pager() {
-      if (shareLinks.length === 0) {
-        return null;
-      }
-      return (
-        <PaginationWrapper
-          activePage={shareLinksActivePage}
-          changePage={this.getShareLinkList}
-          totalItemsCount={totalshareLinks}
-          pagingLimit={shareLinksPagingLimit}
-          align="right"
-        />
-      );
-    }
 
     return (
       <Fragment>
@@ -115,7 +125,13 @@ class ShareLinkSetting extends React.Component {
           </button>
           <h2 className="alert-anchor border-bottom">{t('share_links.share_link_management')}</h2>
         </div>
-        {pager}
+        <Pager
+          links={shareLinks}
+          activePage={shareLinksActivePage}
+          handlePage={this.getShareLinkList}
+          totalLinks={totalshareLinks}
+          limit={shareLinksPagingLimit}
+        />
 
         {(shareLinks.length !== 0) ? (
           <ShareLinkList

+ 59 - 0
src/client/js/components/DuplicatedPathsTable.jsx

@@ -0,0 +1,59 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+import { withUnstatedContainers } from './UnstatedUtils';
+
+import PageContainer from '../services/PageContainer';
+import { convertToNewAffiliationPath } from '../../../lib/util/path-utils';
+
+function DuplicatedPathsTable(props) {
+  const {
+    pageContainer, oldPagePath, existingPaths, t,
+  } = props;
+  const { path } = pageContainer.state;
+
+  return (
+    <table className="table table-bordered grw-duplicated-page-table">
+      <thead>
+        <tr className="d-flex">
+          <th className="w-50">{t('original_path')}</th>
+          <th className="w-50 text-danger">{t('duplicated_path')}</th>
+        </tr>
+      </thead>
+      <tbody className="overflow-auto d-block">
+        {existingPaths.map((existPath) => {
+          const convertedPath = convertToNewAffiliationPath(oldPagePath, path, existPath);
+          return (
+            <tr key={existPath} className="d-flex">
+              <td className="text-break w-50">
+                <a href={convertedPath}>
+                  {convertedPath}
+                </a>
+              </td>
+              <td className="text-break text-danger w-50">
+                {existPath}
+              </td>
+            </tr>
+          );
+        })}
+      </tbody>
+    </table>
+  );
+}
+
+
+/**
+ * Wrapper component for using unstated
+ */
+const PageDuplicateModallWrapper = withUnstatedContainers(DuplicatedPathsTable, [PageContainer]);
+
+DuplicatedPathsTable.propTypes = {
+  t: PropTypes.func.isRequired, //  i18next
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  existingPaths: PropTypes.array.isRequired,
+  oldPagePath: PropTypes.string.isRequired,
+};
+
+
+export default withTranslation()(PageDuplicateModallWrapper);

+ 42 - 0
src/client/js/components/Navbar/AuthorInfo.jsx

@@ -0,0 +1,42 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { userPageRoot } from '@commons/util/path-utils';
+
+import UserPicture from '../User/UserPicture';
+
+const AuthorInfo = (props) => {
+  const { mode, user, date } = props;
+
+  const infoLabel = mode === 'create'
+    ? 'Created by'
+    : 'Updated by';
+  const userLabel = user != null
+    ? <a href={userPageRoot(user)}>{user.name}</a>
+    : <i>Unknown</i>;
+
+  return (
+    <div className="d-flex align-items-center">
+      <div className="mr-2">
+        <UserPicture user={user} size="sm" />
+      </div>
+      <div>
+        <div>{infoLabel} {userLabel}</div>
+        <div className="text-muted text-date">{date}</div>
+      </div>
+    </div>
+  );
+};
+
+AuthorInfo.propTypes = {
+  date: PropTypes.string.isRequired,
+  user: PropTypes.object,
+  mode: PropTypes.oneOf(['create', 'update']),
+};
+
+AuthorInfo.defaultProps = {
+  mode: 'create',
+};
+
+
+export default AuthorInfo;

+ 7 - 12
src/client/js/components/Navbar/GrowiSubNavigation.jsx

@@ -20,8 +20,7 @@ import LikeButton from '../LikeButton';
 import BookmarkButton from '../BookmarkButton';
 import ThreeStrandedButton from './ThreeStrandedButton';
 
-import PageCreator from './PageCreator';
-import RevisionAuthor from './RevisionAuthor';
+import AuthorInfo from './AuthorInfo';
 import DrawerToggler from './DrawerToggler';
 import UserPicture from '../User/UserPicture';
 
@@ -196,16 +195,12 @@ const GrowiSubNavigation = (props) => {
         {/* Page Authors */}
         { (!isCompactMode && !isUserPage) && (
           <ul className="authors text-nowrap border-left d-none d-lg-block d-edit-none">
-            { creator != null && (
-              <li className="pb-1">
-                <PageCreator creator={creator} createdAt={createdAt} />
-              </li>
-            ) }
-            { revisionAuthor != null && (
-              <li className="mt-1 pt-1 border-top">
-                <RevisionAuthor revisionAuthor={revisionAuthor} updatedAt={updatedAt} />
-              </li>
-            ) }
+            <li className="pb-1">
+              <AuthorInfo user={creator} date={createdAt} />
+            </li>
+            <li className="mt-1 pt-1 border-top">
+              <AuthorInfo user={revisionAuthor} date={updatedAt} mode="update" />
+            </li>
           </ul>
         ) }
       </div>

+ 0 - 37
src/client/js/components/Navbar/PageCreator.jsx

@@ -1,37 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { userPageRoot } from '@commons/util/path-utils';
-
-import UserPicture from '../User/UserPicture';
-
-const PageCreator = (props) => {
-  const { creator, createdAt, isCompactMode } = props;
-  const creatInfo = isCompactMode
-    ? (<div>Created at <span className="text-muted">{createdAt}</span></div>)
-    : (<div><div>Created by <a href={userPageRoot(creator)}>{creator.name}</a></div><div className="text-muted text-date">{createdAt}</div></div>);
-  const pictureSize = isCompactMode ? 'xs' : 'sm';
-
-  return (
-    <div className="d-flex align-items-center">
-      <div className="mr-2">
-        <UserPicture user={creator} size={pictureSize} />
-      </div>
-      {creatInfo}
-    </div>
-  );
-};
-
-PageCreator.propTypes = {
-
-  creator: PropTypes.object.isRequired,
-  createdAt: PropTypes.string.isRequired,
-  isCompactMode: PropTypes.bool,
-};
-
-PageCreator.defaultProps = {
-  isCompactMode: false,
-};
-
-
-export default PageCreator;

+ 0 - 41
src/client/js/components/Navbar/RevisionAuthor.jsx

@@ -1,41 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { userPageRoot } from '@commons/util/path-utils';
-
-import UserPicture from '../User/UserPicture';
-
-const RevisionAuthor = (props) => {
-  const { revisionAuthor, updatedAt, isCompactMode } = props;
-  const updateInfo = isCompactMode
-    ? (<div>Updated at <span className="text-muted">{updatedAt}</span></div>)
-    : (
-      <div>
-        <div>Updated by <a href={userPageRoot(revisionAuthor)}>{revisionAuthor.name}</a></div>
-        <div className="text-muted text-date">{updatedAt}</div>
-      </div>
-    );
-  const pictureSize = isCompactMode ? 'xs' : 'sm';
-
-  return (
-    <div className="d-flex align-items-center">
-      <div className="mr-2">
-        <UserPicture user={revisionAuthor} size={pictureSize} />
-      </div>
-      {updateInfo}
-    </div>
-  );
-};
-
-RevisionAuthor.propTypes = {
-
-  revisionAuthor: PropTypes.object.isRequired,
-  updatedAt: PropTypes.string.isRequired,
-  isCompactMode: PropTypes.bool,
-};
-
-RevisionAuthor.defaultProps = {
-  isCompactMode: false,
-};
-
-export default RevisionAuthor;

+ 2 - 2
src/client/js/components/Page/TrashPageAlert.jsx

@@ -15,7 +15,7 @@ import PageDeleteModal from '../PageDeleteModal';
 const TrashPageAlert = (props) => {
   const { t, appContainer, pageContainer } = props;
   const {
-    path, isDeleted, revisionAuthor, updatedAt, hasChildren, isAbleToDeleteCompletely,
+    path, isDeleted, lastUpdateUsername, updatedAt, hasChildren, isAbleToDeleteCompletely,
   } = pageContainer.state;
   const { currentUser } = appContainer;
   const [isEmptyTrashModalShown, setIsEmptyTrashModalShown] = useState(false);
@@ -111,7 +111,7 @@ const TrashPageAlert = (props) => {
       <div className="alert alert-warning py-3 px-4 d-flex align-items-center">
         <div>
           This page is in the trash <i className="icon-trash" aria-hidden="true"></i>.
-          {isDeleted && <span><br /><UserPicture user={revisionAuthor} /> Deleted by {revisionAuthor.name} at {updatedAt}</span>}
+          {isDeleted && <span><br /><UserPicture user={{ username: lastUpdateUsername }} /> Deleted by {lastUpdateUsername} at {updatedAt}</span>}
         </div>
         {(currentUser.admin && path === '/trash' && hasChildren) && renderEmptyButton()}
         {(isDeleted && currentUser != null) && renderTrashPageManagementButtons()}

+ 1 - 1
src/client/js/components/PageAttachment.jsx

@@ -137,7 +137,7 @@ class PageAttachment extends React.Component {
       deleteAttachmentModal = (
         <DeleteAttachmentModal
           isOpen={showModal}
-          animation={false}
+          animation="false"
           toggle={deleteModalClose}
 
           attachmentToDelete={attachmentToDelete}

+ 7 - 11
src/client/js/components/PageAttachment/Attachment.jsx

@@ -54,20 +54,16 @@ export default class Attachment extends React.Component {
       : '';
 
     return (
-      <li className="attachment">
+      <div className="attachment mb-2">
         <span className="mr-1 attachment-userpicture">
           <UserPicture user={attachment.creator} size="sm"></UserPicture>
         </span>
-
-        <a href={attachment.filePathProxied}><i className={formatIcon}></i> {attachment.originalName}</a>
-
-        {fileType}
-
-        {fileInUse}
-
-        {btnDownload}
-        {btnTrash}
-      </li>
+        <a className="mr-2" href={attachment.filePathProxied}><i className={formatIcon}></i> {attachment.originalName}</a>
+        <span className="mr-2">{fileType}</span>
+        <span className="mr-2">{fileInUse}</span>
+        <span className="mr-2">{btnDownload}</span>
+        <span className="mr-2">{btnTrash}</span>
+      </div>
     );
   }
 

+ 0 - 3
src/client/js/components/PageAttachment/PageAttachmentList.jsx

@@ -24,9 +24,6 @@ export default class PageAttachmentList extends React.Component {
 
     return (
       <div>
-        {(attachmentList.length !== 0)
-          && <h5><strong>Attachments</strong></h5>
-        }
         <ul className="pl-2">
           {attachmentList}
         </ul>

+ 6 - 2
src/client/js/components/PageComment/Comment.jsx

@@ -77,7 +77,11 @@ class Comment extends React.PureComponent {
   }
 
   isCurrentUserEqualsToAuthor() {
-    return this.props.comment.creator.username === this.props.appContainer.currentUsername;
+    const { creator } = this.props.comment;
+    if (creator == null) {
+      return false;
+    }
+    return creator.username === this.props.appContainer.currentUsername;
   }
 
   isCurrentRevision() {
@@ -179,7 +183,7 @@ class Comment extends React.PureComponent {
             currentCommentId={commentId}
             commentBody={comment.comment}
             replyTo={undefined}
-            commentCreator={creator.username}
+            commentCreator={creator?.username}
             onCancelButtonClicked={() => this.setState({ isReEdit: false })}
             onCommentButtonClicked={() => this.setState({ isReEdit: false })}
           />

+ 50 - 12
src/client/js/components/PageRenameModal.jsx

@@ -1,4 +1,6 @@
-import React, { useState, useEffect, useCallback } from 'react';
+import React, {
+  useState, useEffect, useCallback,
+} from 'react';
 import PropTypes from 'prop-types';
 
 import {
@@ -7,6 +9,7 @@ import {
 
 import { withTranslation } from 'react-i18next';
 
+import { debounce } from 'throttle-debounce';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { toastError } from '../util/apiNotification';
 
@@ -14,6 +17,8 @@ import AppContainer from '../services/AppContainer';
 import PageContainer from '../services/PageContainer';
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 import ComparePathsTable from './ComparePathsTable';
+import DuplicatedPathsTable from './DuplicatedPathsTable';
+
 
 const PageRenameModal = (props) => {
   const {
@@ -29,19 +34,19 @@ const PageRenameModal = (props) => {
   const [errs, setErrs] = useState(null);
 
   const [subordinatedPages, setSubordinatedPages] = useState([]);
+  const [existingPaths, setExistingPaths] = useState([]);
   const [isRenameRecursively, SetIsRenameRecursively] = useState(true);
   const [isRenameRedirect, SetIsRenameRedirect] = useState(false);
   const [isRenameMetadata, SetIsRenameMetadata] = useState(false);
   const [subordinatedError] = useState(null);
-  const [isDuplicateRecursivelyWithoutExistPath, setIsDuplicateRecursivelyWithoutExistPath] = useState(true);
-
+  const [isRenameRecursivelyWithoutExistPath, setIsRenameRecursivelyWithoutExistPath] = useState(true);
 
   function changeIsRenameRecursivelyHandler() {
     SetIsRenameRecursively(!isRenameRecursively);
   }
 
-  function changeIsDuplicateRecursivelyWithoutExistPathHandler() {
-    setIsDuplicateRecursivelyWithoutExistPath(!isDuplicateRecursivelyWithoutExistPath);
+  function changeIsRenameRecursivelyWithoutExistPathHandler() {
+    setIsRenameRecursivelyWithoutExistPath(!isRenameRecursivelyWithoutExistPath);
   }
 
   function changeIsRenameRedirectHandler() {
@@ -60,7 +65,7 @@ const PageRenameModal = (props) => {
     }
     catch (err) {
       setErrs(err);
-      toastError(t('modal_duplicate.label.Fail to get subordinated pages'));
+      toastError(t('modal_rename.label.Fail to get subordinated pages'));
     }
   }, [appContainer, path, t]);
 
@@ -70,6 +75,30 @@ const PageRenameModal = (props) => {
     }
   }, [props.isOpen, updateSubordinatedList]);
 
+
+  const checkExistPaths = async(newParentPath) => {
+    try {
+      const res = await appContainer.apiv3Get('/page/exist-paths', { fromPath: path, toPath: newParentPath });
+      const { existPaths } = res.data;
+      setExistingPaths(existPaths);
+    }
+    catch (err) {
+      setErrs(err);
+      toastError(t('modal_rename.label.Fail to get exist path'));
+    }
+  };
+
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  const checkExistPathsDebounce = useCallback(
+    debounce(1000, checkExistPaths), [],
+  );
+
+  useEffect(() => {
+    if (pageNameInput !== path) {
+      checkExistPathsDebounce(pageNameInput, subordinatedPages);
+    }
+  }, [pageNameInput, subordinatedPages, path, checkExistPathsDebounce]);
+
   /**
    * change pageNameInput
    * @param {string} value
@@ -144,6 +173,7 @@ const PageRenameModal = (props) => {
             { t('modal_rename.label.Recursively') }
             <p className="form-text text-muted mt-0">{ t('modal_rename.help.recursive') }</p>
           </label>
+          {existingPaths.length !== 0 && (
           <div
             className="custom-control custom-checkbox custom-checkbox-warning"
             style={{ display: isRenameRecursively ? '' : 'none' }}
@@ -151,16 +181,18 @@ const PageRenameModal = (props) => {
             <input
               className="custom-control-input"
               name="withoutExistRecursively"
-              id="cbDuplicatewithoutExistRecursively"
+              id="cbRenamewithoutExistRecursively"
               type="checkbox"
-              checked={isDuplicateRecursivelyWithoutExistPath}
-              onChange={changeIsDuplicateRecursivelyWithoutExistPathHandler}
+              checked={isRenameRecursivelyWithoutExistPath}
+              onChange={changeIsRenameRecursivelyWithoutExistPathHandler}
             />
-            <label className="custom-control-label" htmlFor="cbDuplicatewithoutExistRecursively">
-              { t('modal_duplicate.label.Duplicate without exist path') }
+            <label className="custom-control-label" htmlFor="cbRenamewithoutExistRecursively">
+              { t('modal_rename.label.Rename without exist path') }
             </label>
           </div>
+)}
           {isRenameRecursively && <ComparePathsTable subordinatedPages={subordinatedPages} newPagePath={pageNameInput} />}
+          {isRenameRecursively && existingPaths.length !== 0 && <DuplicatedPathsTable existingPaths={existingPaths} oldPagePath={pageNameInput} />}
         </div>
 
         <div className="custom-control custom-checkbox custom-checkbox-success">
@@ -196,7 +228,13 @@ const PageRenameModal = (props) => {
       </ModalBody>
       <ModalFooter>
         <ApiErrorMessageList errs={errs} targetPath={pageNameInput} />
-        <button type="button" className="btn btn-primary" onClick={rename}>Rename</button>
+        <button
+          type="button"
+          className="btn btn-primary"
+          onClick={rename}
+          disabled={(isRenameRecursively && !isRenameRecursivelyWithoutExistPath && existingPaths.length !== 0)}
+        >Rename
+        </button>
       </ModalFooter>
     </Modal>
   );

+ 2 - 1
src/client/js/services/CommentContainer.js

@@ -78,7 +78,8 @@ export default class CommentContainer extends Container {
 
   async checkAndUpdateImageOfCommentAuthers(comments) {
     const noImageCacheUserIds = comments.filter((comment) => {
-      return comment.creator.imageUrlCached == null;
+      const { creator } = comment;
+      return creator != null && creator.imageUrlCached == null;
     }).map((comment) => {
       return comment.creator._id;
     });

+ 15 - 3
src/client/js/services/PageContainer.js

@@ -45,7 +45,6 @@ export default class PageContainer extends Container {
       pageId: mainContent.getAttribute('data-page-id'),
       revisionId,
       revisionCreatedAt: +mainContent.getAttribute('data-page-revision-created'),
-      revisionAuthor: JSON.parse(mainContent.getAttribute('data-page-revision-author')),
       path,
       tocHtml: '',
       isLiked: JSON.parse(mainContent.getAttribute('data-page-is-liked')),
@@ -58,7 +57,6 @@ export default class PageContainer extends Container {
       sumOfLikers: 0,
 
       createdAt: mainContent.getAttribute('data-page-created-at'),
-      creator: JSON.parse(mainContent.getAttribute('data-page-creator')),
       updatedAt: mainContent.getAttribute('data-page-updated-at'),
       isForbidden:  JSON.parse(mainContent.getAttribute('data-page-is-forbidden')),
       isDeleted:  JSON.parse(mainContent.getAttribute('data-page-is-deleted')),
@@ -74,12 +72,26 @@ export default class PageContainer extends Container {
       // latest(on remote) information
       remoteRevisionId: revisionId,
       revisionIdHackmdSynced: mainContent.getAttribute('data-page-revision-id-hackmd-synced') || null,
-      lastUpdateUsername: undefined,
+      lastUpdateUsername: mainContent.getAttribute('data-page-last-update-username') || null,
       pageIdOnHackmd: mainContent.getAttribute('data-page-id-on-hackmd') || null,
       hasDraftOnHackmd: !!mainContent.getAttribute('data-page-has-draft-on-hackmd'),
       isHackmdDraftUpdatingInRealtime: false,
     };
 
+    // parse creator, lastUpdateUser and revisionAuthor
+    try {
+      this.state.creator = JSON.parse(mainContent.getAttribute('data-page-creator'));
+    }
+    catch (e) {
+      logger.warn('The data of \'data-page-creator\' is invalid', e);
+    }
+    try {
+      this.state.revisionAuthor = JSON.parse(mainContent.getAttribute('data-page-revision-author'));
+    }
+    catch (e) {
+      logger.warn('The data of \'data-page-revision-author\' is invalid', e);
+    }
+
     const { interceptorManager } = this.appContainer;
     interceptorManager.addInterceptor(new DetachCodeBlockInterceptor(appContainer), 10); // process as soon as possible
     interceptorManager.addInterceptor(new DrawioInterceptor(appContainer), 20);

+ 1 - 1
src/client/styles/scss/theme/_apply-colors-light.scss

@@ -3,8 +3,8 @@ $color-list: $color-global !default;
 $bgcolor-list: $bgcolor-global !default;
 $color-list-hover: $color-global !default;
 $bgcolor-list-hover: darken($bgcolor-global, 3%) !default;
-$color-list-active: $color-reversal !default;
 $bgcolor-list-active: $primary !default;
+$color-list-active: color-yiq($bgcolor-list-active) !default;
 $bgcolor-subnav: darken($bgcolor-global, 3%) !default;
 $color-table: $color-global !default;
 $bgcolor-table: null !default;

+ 16 - 1
src/client/styles/scss/theme/_apply-colors.scss

@@ -246,8 +246,11 @@ pre:not(.hljs):not(.CodeMirror-line) {
 .modal {
   .modal-header {
     border-bottom-color: $border-color-theme;
+    .modal-title {
+      color: color-yiq($primary);
+    }
     .close {
-      color: $light;
+      color: color-yiq($primary);
       opacity: 0.5;
       &:hover {
         opacity: 0.9;
@@ -294,6 +297,16 @@ pre:not(.hljs):not(.CodeMirror-line) {
       color: $secondary;
     }
   }
+
+  .modal-title {
+    position: relative;
+  }
+
+  .nav-link {
+    &:hover {
+      background-color: rgba($link-color, 0.08);
+    }
+  }
   .nav-link svg {
     fill: $color-link;
   }
@@ -302,6 +315,8 @@ pre:not(.hljs):not(.CodeMirror-line) {
   }
 
   .grw-nav-slide-hr {
+    position: absolute;
+    bottom: 0px;
     border-color: $color-link;
   }
 }

+ 6 - 5
src/client/styles/scss/theme/default.scss

@@ -108,7 +108,8 @@ html[light] {
 //== Dark Mode
 //
 html[dark] {
-  $primary: #db00c2;
+  $primary: #115cd3;
+  $accent: #db00c2;
 
   // Background colors
   $bgcolor-global: #131418;
@@ -116,7 +117,7 @@ html[dark] {
   $bgcolor-card: darken($bgcolor-global, 5%);
 
   // Font colors
-  $color-global: #a8a8a8;
+  $color-global: $gray-400;
   $color-reversal: $gray-900;
   // $color-header: desaturate($primary, 20%);
   $color-link: #7b9ad5;
@@ -131,7 +132,7 @@ html[dark] {
   // $bgcolor-list: $bgcolor-global; // optional
   // $color-list-hover: $color-global; // optional
   // $bgcolor-list-hover: lighten($bgcolor-global, 3%); // optional
-  $color-list-active: white; // optional
+  // $color-list-active:white ; // optional
   // $bgcolor-list-active: $primary; // optional
 
   // Table colors
@@ -143,7 +144,7 @@ html[dark] {
 
   // Navbar
   $bgcolor-navbar: #2a2929;
-  $bgcolor-search-top-dropdown: $primary;
+  $bgcolor-search-top-dropdown: $accent;
   $border-image-navbar: linear-gradient(to right, #44bfe3 0%, #b04aff 50%, #ff1794 100%);
 
   // Logo colors
@@ -156,7 +157,7 @@ html[dark] {
   $text-shadow-sidebar-nav-item-active: 0px 0px 10px #0099ff; // optional
   // Sidebar resize button
   $color-resize-button: white;
-  $bgcolor-resize-button: $primary;
+  $bgcolor-resize-button: $accent;
   $color-resize-button-hover: white;
   $bgcolor-resize-button-hover: darken($bgcolor-resize-button, 5%);
   // Sidebar contents

+ 18 - 0
src/server/models/page.js

@@ -258,6 +258,17 @@ class PageQueryBuilder {
     return this;
   }
 
+  addConditionToListByPathsArray(paths) {
+    this.query = this.query
+      .and({
+        path: {
+          $in: paths,
+        },
+      });
+
+    return this;
+  }
+
   populateDataToList(userPublicFields) {
     this.query = this.query
       .populate({
@@ -1291,6 +1302,13 @@ module.exports = function(crowi) {
     return targetPage;
   };
 
+  pageSchema.statics.findListByPathsArray = async function(paths) {
+    const queryBuilder = new PageQueryBuilder(this.find());
+    queryBuilder.addConditionToListByPathsArray(paths);
+
+    return await queryBuilder.query.exec();
+  };
+
   // TODO: transplant to service/page.js because page deletion affects various models data
   pageSchema.statics.handlePrivatePagesForDeletedGroup = async function(deletedGroup, action, transferToUserGroupId) {
     const Page = mongoose.model('Page');

+ 62 - 0
src/server/routes/apiv3/page.js

@@ -7,6 +7,7 @@ const { body, query } = require('express-validator');
 
 const router = express.Router();
 
+const { convertToNewAffiliationPath } = require('../../../lib/util/path-utils');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 /**
@@ -138,6 +139,10 @@ module.exports = (crowi) => {
       body('hierarchyType').isString().isIn(['allSubordinatedPage', 'decideHierarchy']),
       body('hierarchyValue').isNumeric(),
     ],
+    exist: [
+      query('fromPath').isString(),
+      query('toPath').isString(),
+    ],
   };
 
   /**
@@ -253,6 +258,63 @@ module.exports = (crowi) => {
     return stream.pipe(res);
   });
 
+  /**
+   * @swagger
+   *
+   *    /page/exist-paths:
+   *      get:
+   *        tags: [Page]
+   *        summary: /page/exist-paths
+   *        description: Get already exist paths
+   *        operationId: getAlreadyExistPaths
+   *        parameters:
+   *          - name: fromPath
+   *            in: query
+   *            description: old parent path
+   *            schema:
+   *              type: string
+   *          - name: toPath
+   *            in: query
+   *            description: new parent path
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: Succeeded to retrieve pages.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    existPaths:
+   *                      type: object
+   *                      description: Paths are already exist in DB
+   *          500:
+   *            description: Internal server error.
+   */
+  router.get('/exist-paths', loginRequired, validator.exist, apiV3FormValidator, async(req, res) => {
+    const { fromPath, toPath } = req.query;
+
+    try {
+      const fromPage = await Page.findByPath(fromPath);
+      const fromPageDescendants = await Page.findManageableListWithDescendants(fromPage, req.user);
+
+      const toPathDescendantsArray = fromPageDescendants.map((subordinatedPage) => {
+        return convertToNewAffiliationPath(fromPath, toPath, subordinatedPage.path);
+      });
+
+      const existPages = await Page.findListByPathsArray(toPathDescendantsArray);
+      const existPaths = existPages.map(page => page.path);
+
+      return res.apiv3({ existPaths });
+
+    }
+    catch (err) {
+      logger.error('Failed to get exist path', err);
+      return res.apiv3Err(err, 500);
+    }
+
+  });
+
   // TODO GW-2746 bulk export pages
   // /**
   //  * @swagger

+ 5 - 0
src/server/routes/attachment.js

@@ -152,6 +152,11 @@ module.exports = function(crowi, app) {
    * @param {Attachment} attachment
    */
   async function isDeletableByUser(user, attachment) {
+    // deletable if creator is null
+    if (attachment.creator == null) {
+      return true;
+    }
+
     const ownerId = attachment.creator._id || attachment.creator;
     if (attachment.page == null) { // when profile image
       return user.id === ownerId.toString();

+ 7 - 2
src/server/routes/page.js

@@ -221,12 +221,17 @@ module.exports = function(crowi, app) {
 
   function addRenderVarsForPage(renderVars, page) {
     renderVars.page = page;
-    renderVars.page.creator = renderVars.page.creator.toObject();
     renderVars.revision = page.revision;
-    renderVars.revision.author = renderVars.revision.author.toObject();
     renderVars.pageIdOnHackmd = page.pageIdOnHackmd;
     renderVars.revisionHackmdSynced = page.revisionHackmdSynced;
     renderVars.hasDraftOnHackmd = page.hasDraftOnHackmd;
+
+    if (page.creator != null) {
+      renderVars.page.creator = renderVars.page.creator.toObject();
+    }
+    if (page.revision.author != null) {
+      renderVars.revision.author = renderVars.revision.author.toObject();
+    }
   }
 
   function addRenderVarsForPresentation(renderVars, page) {

+ 2 - 2
src/server/service/search-delegator/elasticsearch.js

@@ -51,7 +51,7 @@ class ElasticsearchDelegator {
   }
 
   shouldIndexed(page) {
-    return page.creator != null && page.revision != null && page.redirectTo == null;
+    return page.revision != null && page.redirectTo == null;
   }
 
   initClient() {
@@ -310,7 +310,7 @@ class ElasticsearchDelegator {
     let document = {
       path: page.path,
       body: page.revision.body,
-      username: page.creator.username,
+      username: page.creator?.username,
       comment_count: page.commentCount,
       bookmark_count: bookmarkCount,
       like_count: page.liker.length || 0,

+ 3 - 2
src/server/views/widget/page_content.html

@@ -5,7 +5,7 @@
   data-page-id="{% if page %}{{ page._id.toString() }}{% endif %}"
   data-page-revision-id="{% if revision %}{{ revision._id.toString() }}{% endif %}"
   data-page-revision-created="{% if revision %}{{ revision.createdAt|datetz('U') }}{% endif %}"
-  data-page-revision-author="{% if revision %}{{ revision.author|json }}{% endif %}"
+  data-page-revision-author="{% if revision && revision.author %}{{ revision.author|json }}{% endif %}"
   data-page-revision-id-hackmd-synced="{% if revisionHackmdSynced %}{{ revisionHackmdSynced.toString() }}{% endif %}"
   data-page-id-on-hackmd="{% if pageIdOnHackmd %}{{ pageIdOnHackmd.toString() }}{% endif %}"
   data-page-has-draft-on-hackmd="{% if hasDraftOnHackmd %}{{ hasDraftOnHackmd.toString() }}{% endif %}"
@@ -20,7 +20,8 @@
   data-page-is-able-to-delete-completely="{% if user.canDeleteCompletely(page.creator._id) %}true{% else %}false{% endif %}"
   data-slack-channels="{{ slack|default('') }}"
   data-page-created-at="{% if page %}{{ page.createdAt|datetz('Y/m/d H:i:s') }}{% endif %}"
-  data-page-creator="{% if page %}{{ page.creator|json }}{% endif %}"
+  data-page-creator="{% if page && page.creator %}{{ page.creator|json }}{% endif %}"
+  data-page-last-update-username="{% if page && page.lastUpdateUser %}{{ page.lastUpdateUser.name }}{% endif %}"
   data-page-updated-at="{% if page %}{{ page.updatedAt|datetz('Y/m/d H:i:s') }}{% endif %}"
   data-page-has-children="{% if pages.length > 0 %}true{% else %}false{% endif %}"
   data-page-user="{% if pageUser %}{{ pageUser|json }}{% else %}null{% endif %}"