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

Merge pull request #5200 from weseek/feat/identical-pages

feat: Identical pages
Yuki Takei 4 лет назад
Родитель
Сommit
157c66b670

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

@@ -89,7 +89,7 @@ Object.assign(componentMappings, {
 
   'search-page': <SearchPage crowi={appContainer} />,
   'all-in-app-notifications': <InAppNotificationPage />,
-  'identical-path-page-list': <IdenticalPathPage />,
+  'identical-path-page': <IdenticalPathPage />,
 
   // 'revision-history': <PageHistory pageId={pageId} />,
   'tags-page': <TagsList crowi={appContainer} />,

+ 11 - 6
packages/app/src/client/services/ContextExtractor.tsx

@@ -6,7 +6,7 @@ import {
   useIsDeletable, useIsDeleted, useIsNotCreatable, useIsPageExist, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
-  useSlackChannels, useNotFoundTargetPathOrId, useIsSearchPage,
+  useSlackChannels, useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath,
 } from '../../stores/context';
 import {
   useIsDeviceSmallerThanMd, useIsDeviceSmallerThanLg,
@@ -23,6 +23,7 @@ const ContextExtractorOnce: FC = () => {
 
   const mainContent = document.querySelector('#content-main');
   const notFoundContent = document.getElementById('growi-pagetree-not-found-context');
+  const forbiddenContent = document.getElementById('forbidden-page');
 
   /*
    * App Context from DOM
@@ -50,13 +51,15 @@ const ContextExtractorOnce: FC = () => {
   const updatedAt: Date | null = (updatedAtAttribute != null) ? new Date(updatedAtAttribute) : null;
 
   const deletedAt = mainContent?.getAttribute('data-page-deleted-at') || null;
-  const isUserPage = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull);
+  const isIdenticalPath = JSON.parse(mainContent?.getAttribute('data-identical-path') || jsonNull) ?? false;
+  const isUserPage = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull) != null;
   const isTrashPage = _isTrashPage(path);
-  const isDeleted = JSON.parse(mainContent?.getAttribute('data-page-is-deleted') || jsonNull);
-  const isDeletable = JSON.parse(mainContent?.getAttribute('data-page-is-deletable') || jsonNull);
-  const isNotCreatable = JSON.parse(mainContent?.getAttribute('data-page-is-not-creatable') || jsonNull);
-  const isAbleToDeleteCompletely = JSON.parse(mainContent?.getAttribute('data-page-is-able-to-delete-completely') || jsonNull);
+  const isDeleted = JSON.parse(mainContent?.getAttribute('data-page-is-deleted') || jsonNull) ?? false;
+  const isDeletable = JSON.parse(mainContent?.getAttribute('data-page-is-deletable') || jsonNull) ?? false;
+  const isNotCreatable = JSON.parse(mainContent?.getAttribute('data-page-is-not-creatable') || jsonNull) ?? false;
+  const isAbleToDeleteCompletely = JSON.parse(mainContent?.getAttribute('data-page-is-able-to-delete-completely') || jsonNull) ?? false;
   const isPageExist = mainContent?.getAttribute('data-page-id') != null;
+  const isForbidden = forbiddenContent != null;
   const pageUser = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull);
   const hasChildren = JSON.parse(mainContent?.getAttribute('data-page-has-children') || jsonNull);
   const templateTagData = mainContent?.getAttribute('data-template-tags') || null;
@@ -97,11 +100,13 @@ const ContextExtractorOnce: FC = () => {
   useDeletedAt(deletedAt);
   useHasChildren(hasChildren);
   useHasDraftOnHackmd(hasDraftOnHackmd);
+  useIsIdenticalPath(isIdenticalPath);
   useIsAbleToDeleteCompletely(isAbleToDeleteCompletely);
   useIsDeletable(isDeletable);
   useIsDeleted(isDeleted);
   useIsNotCreatable(isNotCreatable);
   useIsPageExist(isPageExist);
+  useIsForbidden(isForbidden);
   useIsTrashPage(isTrashPage);
   useIsUserPage(isUserPage);
   useLastUpdateUsername(lastUpdateUsername);

+ 0 - 26
packages/app/src/components/DuplicatePage.tsx

@@ -1,26 +0,0 @@
-import React, { FC } from 'react';
-import { DevidedPagePath } from '@growi/core';
-import { useTranslation } from 'react-i18next';
-
-
-type DuplicatePageAlertProps = {
-  path : string,
-}
-
-const DuplicatePageAlert : FC<DuplicatePageAlertProps> = (props: DuplicatePageAlertProps) => {
-  const { path } = props;
-  const { t } = useTranslation();
-  const devidedPath = new DevidedPagePath(path);
-
-  return (
-    <div className="alert alert-warning py-3">
-      <h5 className="font-weight-bold mt-1">{t('duplicated_page_alert.same_page_name_exists', { pageName: devidedPath.latter })}</h5>
-      <p>
-        {t('duplicated_page_alert.same_page_name_exists_at_path',
-          { path: devidedPath.isFormerRoot ? '/' : devidedPath.former, pageName: devidedPath.latter })}<br />
-        <p dangerouslySetInnerHTML={{ __html: t('See_more_detail_on_new_schema', { url: t('GROWI.5.0_new_schema') }) }} />
-      </p>
-      <p className="mb-1">{t('duplicated_page_alert.select_page_to_see')}</p>
-    </div>
-  );
-};

+ 92 - 6
packages/app/src/components/IdenticalPathPage.tsx

@@ -1,14 +1,100 @@
-import React, { FC } from 'react';
+import React, {
+  FC,
+} from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { DevidedPagePath } from '@growi/core';
+
+import { useCurrentPagePath } from '~/stores/context';
+
+import PageListItem from './Page/PageListItem';
+
+
+type IdenticalPathAlertProps = {
+  path? : string | null,
+}
+
+const IdenticalPathAlert : FC<IdenticalPathAlertProps> = (props: IdenticalPathAlertProps) => {
+  const { path } = props;
+  const { t } = useTranslation();
+
+  let _path = '――';
+  let _pageName = '――';
+
+  if (path != null) {
+    const devidedPath = new DevidedPagePath(path);
+    _path = devidedPath.isFormerRoot ? '/' : devidedPath.former;
+    _pageName = devidedPath.latter;
+  }
+
+
+  return (
+    <div className="alert alert-warning py-3">
+      <h5 className="font-weight-bold mt-1">{t('duplicated_page_alert.same_page_name_exists', { pageName: _pageName })}</h5>
+      <p>
+        {t('duplicated_page_alert.same_page_name_exists_at_path',
+          { path: _path, pageName: _pageName })}<br />
+        <p
+          // eslint-disable-next-line react/no-danger
+          dangerouslySetInnerHTML={{ __html: t('See_more_detail_on_new_schema', { url: t('GROWI.5.0_new_schema') }) }}
+        />
+      </p>
+      <p className="mb-1">{t('duplicated_page_alert.select_page_to_see')}</p>
+    </div>
+  );
+};
+
 
 type IdenticalPathPageProps= {
   // add props and types here
 }
-const IdenticalPathPage:FC<IdenticalPathPageProps> = (props:IdenticalPathPageProps) => {
+
+
+const jsonNull = 'null';
+
+const IdenticalPathPage:FC<IdenticalPathPageProps> = (props: IdenticalPathPageProps) => {
+
+  const identicalPageDocument = document.getElementById('identical-path-page');
+  const pageDataList = JSON.parse(identicalPageDocument?.getAttribute('data-identical-page-data-list') || jsonNull);
+  const shortbodyMap = JSON.parse(identicalPageDocument?.getAttribute('data-shortody-map') || jsonNull);
+
+  const { data: currentPath } = useCurrentPagePath();
+
   return (
-    <div>
-      {/* Todo: show alert */}
-      {/* Todo: show identical path page list */}
-      IdenticalPathPageList
+    <div className="d-flex flex-column flex-lg-row-reverse">
+
+      <div className="grw-side-contents-container">
+        <div className="grw-side-contents-sticky-container">
+          <div className="border-bottom pb-1">
+            {/* <PageAccessories isNotFoundPage={!isPageExist} /> */}
+          </div>
+        </div>
+      </div>
+
+      <div className="flex-grow-1 flex-basis-0 mw-0">
+
+        <IdenticalPathAlert path={currentPath} />
+
+        <div className="page-list">
+          <ul className="page-list-ul list-group-flush border px-3">
+            {pageDataList.map((data) => {
+              return (
+                <PageListItem
+                  key={data.pageData._id}
+                  page={data}
+                  isSelected={false}
+                  isChecked={false}
+                  isEnableActions
+                  shortBody={shortbodyMap[data.pageData._id]}
+                // Todo: add onClickDeleteButton when delete feature implemented
+                />
+              );
+            })}
+          </ul>
+        </div>
+
+      </div>
+
     </div>
   );
 };

+ 36 - 35
packages/app/src/components/Navbar/GrowiSubNavigation.jsx

@@ -2,13 +2,16 @@ import React, { useCallback } from 'react';
 import PropTypes from 'prop-types';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import {
-  EditorMode, useDrawerMode, useEditorMode, useIsDeviceSmallerThanMd,
+  EditorMode, useDrawerMode, useEditorMode, useIsDeviceSmallerThanMd, useIsAbleToShowPageManagement, useIsAbleToShowTagLabel,
+  useIsAbleToShowPageEditorModeManager, useIsAbleToShowPageAuthors,
 } from '~/stores/ui';
-import { useCurrentCreatedAt, useCurrentUpdatedAt } from '~/stores/context';
+import {
+  useCurrentCreatedAt, useCurrentUpdatedAt, useCurrentPageId, useRevisionId, useCurrentPagePath, useIsDeletable,
+  useIsAbleToDeleteCompletely, useCreator, useRevisionAuthor, useIsPageExist, useIsGuestUser,
+} from '~/stores/context';
+import { useSWRTagsInfo } from '~/stores/page';
 
 import TagLabels from '../Page/TagLabels';
 import SubNavButtons from './SubNavButtons';
@@ -28,30 +31,29 @@ const GrowiSubNavigation = (props) => {
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: createdAt } = useCurrentCreatedAt();
   const { data: updatedAt } = useCurrentUpdatedAt();
+  const { data: pageId } = useCurrentPageId();
+  const { data: revisionId } = useRevisionId();
+  const { data: path } = useCurrentPagePath();
+  const { data: isDeletable } = useIsDeletable();
+  const { data: isAbleToDeleteCompletely } = useIsAbleToDeleteCompletely();
+  const { data: creator } = useCreator();
+  const { data: revisionAuthor } = useRevisionAuthor();
+  const { data: isGuestUser } = useIsGuestUser();
+
+  const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
+  const { data: isAbleToShowTagLabel } = useIsAbleToShowTagLabel();
+  const { data: isAbleToShowPageEditorModeManager } = useIsAbleToShowPageEditorModeManager();
+  const { data: isAbleToShowPageAuthors } = useIsAbleToShowPageAuthors();
+
+  const { mutate: mutateSWRTagsInfo, data: TagsInfoData } = useSWRTagsInfo(pageId);
 
   const {
-    appContainer, pageContainer, editorContainer, isCompactMode,
+    editorContainer, isCompactMode,
   } = props;
 
-  const {
-    pageId,
-    revisionId,
-    path,
-    isDeletable,
-    isAbleToDeleteCompletely,
-    creator,
-    revisionAuthor,
-    isPageExist,
-    isTrashPage,
-    tags,
-  } = pageContainer.state;
-
-  const { isGuestUser, isSharedUser } = appContainer;
-  const isEditorMode = editorMode !== EditorMode.View;
-  // Tags cannot be edited while the new page and editorMode is view
-  const isTagLabelHidden = (editorMode !== EditorMode.Editor && !isPageExist);
-
-  const isAbleToShowPageManagement = isPageExist && !isTrashPage && !isSharedUser && !isEditorMode;
+  const isViewMode = editorMode === EditorMode.View;
+  const isEditorMode = !isViewMode;
+
   function onPageEditorModeButtonClicked(viewType) {
     mutateEditorMode(viewType);
   }
@@ -63,10 +65,10 @@ const GrowiSubNavigation = (props) => {
     }
 
     try {
-      const { tags } = await apiPost('/tags.update', { pageId, tags: newTags });
+      const { tags } = await apiPost('/tags.update', { pageId, revisionId, tags: newTags });
 
-      // update pageContainer.state
-      pageContainer.setState({ tags });
+      // revalidate SWRTagsInfo
+      mutateSWRTagsInfo();
       // update editorContainer.state
       editorContainer.setState({ tags });
 
@@ -90,9 +92,9 @@ const GrowiSubNavigation = (props) => {
         ) }
 
         <div className="grw-path-nav-container">
-          { pageContainer.isAbleToShowTagLabel && !isCompactMode && !isTagLabelHidden && (
+          { isAbleToShowTagLabel && !isCompactMode && (
             <div className="grw-taglabels-container">
-              <TagLabels tags={tags} tagsUpdateInvoked={tagsUpdatedHandler} />
+              <TagLabels tags={TagsInfoData?.tags || []} tagsUpdateInvoked={tagsUpdatedHandler} />
             </div>
           ) }
           <PagePathNav pageId={pageId} pagePath={path} isSingleLineMode={isEditorMode} isCompactMode={isCompactMode} />
@@ -110,10 +112,11 @@ const GrowiSubNavigation = (props) => {
             path={path}
             isDeletable={isDeletable}
             isAbleToDeleteCompletely={isAbleToDeleteCompletely}
-            willShowPageManagement={isAbleToShowPageManagement}
+            isViewMode={isViewMode}
+            isAbleToShowPageManagement={isAbleToShowPageManagement}
           />
           <div className="mt-2">
-            {pageContainer.isAbleToShowPageEditorModeManager && (
+            {isAbleToShowPageEditorModeManager && (
               <PageEditorModeManager
                 onPageEditorModeButtonClicked={onPageEditorModeButtonClicked}
                 isBtnDisabled={isGuestUser}
@@ -125,7 +128,7 @@ const GrowiSubNavigation = (props) => {
         </div>
 
         {/* Page Authors */}
-        { (pageContainer.isAbleToShowPageAuthors && !isCompactMode) && (
+        { (isAbleToShowPageAuthors && !isCompactMode) && (
           <ul className="authors text-nowrap border-left d-none d-lg-block d-edit-none py-2 pl-4 mb-0 ml-3">
             <li className="pb-1">
               <AuthorInfo user={creator} date={createdAt} locate="subnav" />
@@ -143,12 +146,10 @@ const GrowiSubNavigation = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const GrowiSubNavigationWrapper = withUnstatedContainers(GrowiSubNavigation, [AppContainer, PageContainer, EditorContainer]);
+const GrowiSubNavigationWrapper = withUnstatedContainers(GrowiSubNavigation, [EditorContainer]);
 
 
 GrowiSubNavigation.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 
   isCompactMode: PropTypes.bool,

+ 14 - 17
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -10,7 +10,6 @@ import { useSWRBookmarkInfo } from '../../stores/bookmark';
 import { toastError } from '../../client/util/apiNotification';
 import { apiv3Put } from '../../client/util/apiv3-client';
 import { useSWRxLikerList } from '../../stores/user';
-import { useEditorMode } from '~/stores/ui';
 import { useIsGuestUser } from '~/stores/context';
 
 type SubNavButtonsProps= {
@@ -18,18 +17,16 @@ type SubNavButtonsProps= {
   pageId: string,
   revisionId: string,
   path: string,
-  willShowPageManagement: boolean,
+  isViewMode: boolean
+  isAbleToShowPageManagement: boolean,
   isDeletable: boolean,
   isAbleToDeleteCompletely: boolean,
 }
 const SubNavButtons: FC<SubNavButtonsProps> = (props: SubNavButtonsProps) => {
   const {
-    isCompactMode, pageId, revisionId, path, willShowPageManagement, isDeletable, isAbleToDeleteCompletely,
+    isCompactMode, pageId, revisionId, path, isViewMode, isAbleToShowPageManagement, isDeletable, isAbleToDeleteCompletely,
   } = props;
 
-  const { data: editorMode } = useEditorMode();
-  const isViewMode = editorMode === 'view';
-
   const { data: isGuestUser } = useIsGuestUser();
 
   const { data: pageInfo, error: pageInfoError, mutate: mutatePageInfo } = useSWRPageInfo(pageId);
@@ -95,19 +92,19 @@ const SubNavButtons: FC<SubNavButtonsProps> = (props: SubNavButtonsProps) => {
             onBookMarkClicked={bookmarkClickHandler}
           >
           </PageReactionButtons>
+          { isAbleToShowPageManagement && (
+            <PageManagement
+              pageId={pageId}
+              revisionId={revisionId}
+              path={path}
+              isCompactMode={isCompactMode}
+              isDeletable={isDeletable}
+              isAbleToDeleteCompletely={isAbleToDeleteCompletely}
+            >
+            </PageManagement>
+          )}
         </>
       )}
-      {willShowPageManagement && (
-        <PageManagement
-          pageId={pageId}
-          revisionId={revisionId}
-          path={path}
-          isCompactMode={isCompactMode}
-          isDeletable={isDeletable}
-          isAbleToDeleteCompletely={isAbleToDeleteCompletely}
-        >
-        </PageManagement>
-      )}
     </div>
   );
 };

+ 9 - 4
packages/app/src/components/Page/PageListItem.tsx

@@ -62,12 +62,16 @@ const PageListItem: FC<Props> = memo((props:Props) => {
     }
   }, [isDeviceSmallerThanLg, onClickSearchResultItem, pageData._id]);
 
+  const styleListGroupItem = (!isDeviceSmallerThanLg && onClickCheckbox != null) ? 'list-group-item-action' : '';
   // background color of list item changes when class "active" exists under 'grw-search-result-item'
-  const responsiveListStyleClass = `${isDeviceSmallerThanLg ? '' : `list-group-item-action ${isSelected ? 'active' : ''}`}`;
+  const styleActive = !isDeviceSmallerThanLg && isSelected ? 'active' : '';
+  const styleBorder = onClickCheckbox != null ? 'border-bottom' : 'list-group-item p-0';
+
   return (
     <li
       key={pageData._id}
-      className={`w-100 grw-search-result-item border-bottom ${responsiveListStyleClass}`}
+      className={`w-100 grw-search-result-item search-result-list ${styleListGroupItem} ${styleActive} ${styleBorder}}`
+      }
     >
       <div
         className="h-100 text-break"
@@ -98,7 +102,7 @@ const PageListItem: FC<Props> = memo((props:Props) => {
             <div className="d-flex align-items-center mb-2">
               {/* Picture */}
               <span className="mr-2 d-none d-md-block">
-                <UserPicture user={pageData.lastUpdateUser} size="sm" />
+                <UserPicture user={pageData.lastUpdateUser} size="md" />
               </span>
               {/* page title */}
               <Clamp lines={1}>
@@ -109,7 +113,7 @@ const PageListItem: FC<Props> = memo((props:Props) => {
 
               {/* page meta */}
               <div className="d-none d-md-flex item-meta py-0 px-1">
-                <PageListMeta page={pageData} bookmarkCount={pageMeta.bookmarkCount} />
+                <PageListMeta page={pageData} bookmarkCount={pageMeta.bookmarkCount} shouldSpaceOutIcon />
               </div>
               {/* doropdown icon includes page control buttons */}
               <div className="item-control ml-auto">
@@ -118,6 +122,7 @@ const PageListItem: FC<Props> = memo((props:Props) => {
                   onClickDeleteButtonHandler={props.onClickDeleteButton}
                   isEnableActions={isEnableActions}
                   isDeletable={!isTopPage(pageData.path)}
+                  // Todo: add onClickRenameButtonHandler
                 />
               </div>
             </div>

+ 13 - 17
packages/app/src/components/Page/RenderTagLabels.jsx → packages/app/src/components/Page/RenderTagLabels.tsx

@@ -1,19 +1,23 @@
 import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 import { UncontrolledTooltip } from 'reactstrap';
 
-const RenderTagLabels = React.memo((props) => {
-  const {
-    t, tags, isGuestUser,
-  } = props;
+type RenderTagLabelsProps = {
+  tags: string[],
+  isGuestUser: boolean,
+  openEditorModal?: () => void,
+}
+
+const RenderTagLabels = React.memo((props: RenderTagLabelsProps) => {
+  const { tags, isGuestUser, openEditorModal } = props;
+  const { t } = useTranslation();
 
   function openEditorHandler() {
-    if (props.openEditorModal == null) {
+    if (openEditorModal == null) {
       return;
     }
-    props.openEditorModal();
+    openEditorModal();
   }
 
   // activate suspense
@@ -22,7 +26,6 @@ const RenderTagLabels = React.memo((props) => {
   }
 
   const isTagsEmpty = tags.length === 0;
-
   const tagElements = tags.map((tag) => {
     return (
       <a key={tag} href={`/_search?q=tag:${tag}`} className="grw-tag-label badge badge-secondary mr-2">
@@ -54,12 +57,5 @@ const RenderTagLabels = React.memo((props) => {
 
 });
 
-RenderTagLabels.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-
-  tags: PropTypes.array,
-  openEditorModal: PropTypes.func,
-  isGuestUser: PropTypes.bool.isRequired,
-};
 
-export default withTranslation()(RenderTagLabels);
+export default RenderTagLabels;

+ 0 - 113
packages/app/src/components/Page/TagLabels.jsx

@@ -1,113 +0,0 @@
-import React, { Suspense } from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import EditorContainer from '~/client/services/EditorContainer';
-import PageContainer from '~/client/services/PageContainer';
-import { EditorMode } from '~/stores/ui';
-import { toastError, toastSuccess } from '~/client/util/apiNotification';
-
-import RenderTagLabels from './RenderTagLabels';
-import TagEditModal from './TagEditModal';
-
-class TagLabels extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isTagEditModalShown: false,
-    };
-
-    this.openEditorModal = this.openEditorModal.bind(this);
-    this.closeEditorModal = this.closeEditorModal.bind(this);
-  }
-
-
-  openEditorModal() {
-    this.setState({ isTagEditModalShown: true });
-  }
-
-  closeEditorModal() {
-    this.setState({ isTagEditModalShown: false });
-  }
-
-  async tagsUpdatedHandler(newTags) {
-    const {
-      appContainer, editorContainer, pageContainer, editorMode,
-    } = this.props;
-
-    const { pageId, revisionId } = pageContainer.state;
-    // It will not be reflected in the DB until the page is refreshed
-    if (editorMode === EditorMode.Editor) {
-      return editorContainer.setState({ tags: newTags });
-    }
-    try {
-      const { tags, savedPage } = await appContainer.apiPost('/tags.update', {
-        pageId, tags: newTags, revisionId,
-      });
-      editorContainer.setState({ tags });
-      pageContainer.updatePageMetaData(savedPage, savedPage.revision, tags);
-      toastSuccess('updated tags successfully');
-    }
-    catch (err) {
-      toastError(err, 'fail to update tags');
-    }
-  }
-
-
-  render() {
-    const { appContainer, tagsUpdateInvoked, tags } = this.props;
-
-    return (
-      <>
-
-        <form className="grw-tag-labels form-inline">
-          <i className="tag-icon icon-tag mr-2"></i>
-          <Suspense fallback={<span className="grw-tag-label badge badge-secondary">―</span>}>
-            <RenderTagLabels
-              tags={tags}
-              openEditorModal={this.openEditorModal}
-              isGuestUser={appContainer.isGuestUser}
-            />
-          </Suspense>
-        </form>
-
-        <TagEditModal
-          tags={tags}
-          isOpen={this.state.isTagEditModalShown}
-          onClose={this.closeEditorModal}
-          appContainer={this.props.appContainer}
-          onTagsUpdated={tagsUpdateInvoked}
-        />
-
-      </>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const TagLabelsUnstatedWrapper = withUnstatedContainers(TagLabels, [AppContainer, EditorContainer, PageContainer]);
-
-TagLabels.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-  editorMode: PropTypes.string.isRequired,
-  tags: PropTypes.arrayOf(String),
-  tagsUpdateInvoked: PropTypes.func,
-};
-
-// wrapping tsx component returned by withUnstatedContainers to avoid type error when this component used in other tsx components.
-const TagLabelsWrapper = (props) => {
-  return <TagLabelsUnstatedWrapper {...props}></TagLabelsUnstatedWrapper>;
-};
-export default withTranslation()(TagLabelsWrapper);

+ 58 - 0
packages/app/src/components/Page/TagLabels.tsx

@@ -0,0 +1,58 @@
+import React, { FC, Suspense, useState } from 'react';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+import AppContainer from '~/client/services/AppContainer';
+
+import RenderTagLabels from './RenderTagLabels';
+import TagEditModal from './TagEditModal';
+
+type TagLabels = {
+  tags: string[],
+  appContainer: AppContainer,
+  tagsUpdateInvoked?: () => Promise<void>,
+}
+
+
+const TagLabels:FC<TagLabels> = (props:TagLabels) => {
+  const { tags, appContainer, tagsUpdateInvoked } = props;
+
+  const [isTagEditModalShown, setIsTagEditModalShown] = useState(false);
+
+  const openEditorModal = () => {
+    setIsTagEditModalShown(true);
+  };
+
+  const closeEditorModal = () => {
+    setIsTagEditModalShown(false);
+  };
+
+  return (
+    <>
+      <form className="grw-tag-labels form-inline">
+        <i className="tag-icon icon-tag mr-2"></i>
+        <Suspense fallback={<span className="grw-tag-label badge badge-secondary">―</span>}>
+          <RenderTagLabels
+            tags={tags}
+            openEditorModal={openEditorModal}
+            isGuestUser={appContainer.isGuestUser}
+          />
+        </Suspense>
+      </form>
+
+      <TagEditModal
+        tags={tags}
+        isOpen={isTagEditModalShown}
+        onClose={closeEditorModal}
+        onTagsUpdated={tagsUpdateInvoked}
+      />
+
+    </>
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const TagLabelsUnstatedWrapper = withUnstatedContainers(TagLabels, [AppContainer]);
+
+export default TagLabelsUnstatedWrapper;

+ 12 - 1
packages/app/src/components/SearchPage/SearchResultContentSubNavigation.tsx

@@ -1,11 +1,16 @@
 import React, { FC } from 'react';
+
 import { pagePathUtils } from '@growi/core';
+
+import { EditorMode, useEditorMode } from '~/stores/ui';
+
 import PagePathNav from '../PagePathNav';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '../../client/services/AppContainer';
 import { useSWRTagsInfo } from '../../stores/page';
 import SubNavButtons from '../Navbar/SubNavButtons';
 
+
 type Props = {
   appContainer:AppContainer
   pageId: string,
@@ -23,11 +28,16 @@ const SearchResultContentSubNavigation: FC<Props> = (props : Props) => {
 
   const { isTrashPage, isDeletablePage } = pagePathUtils;
 
+  const { data: editorMode } = useEditorMode();
+
   const { data: tagInfoData, error: tagInfoError } = useSWRTagsInfo(pageId);
 
   if (tagInfoError != null || tagInfoData == null) {
     return <></>;
   }
+
+  const isViewMode = editorMode === EditorMode.View;
+
   const isPageDeletable = isDeletablePage(path);
   const { isSharedUser } = appContainer;
   const isAbleToShowPageManagement = !(isTrashPage(path)) && !isSharedUser;
@@ -50,9 +60,10 @@ const SearchResultContentSubNavigation: FC<Props> = (props : Props) => {
             pageId={pageId}
             revisionId={revisionId}
             path={path}
+            isViewMode={isViewMode}
             isDeletable={isPageDeletable}
             isAbleToDeleteCompletely={false}
-            willShowPageManagement={isAbleToShowPageManagement}
+            isAbleToShowPageManagement={isAbleToShowPageManagement}
           >
           </SubNavButtons>
         </div>

+ 1 - 0
packages/app/src/server/models/page.ts

@@ -218,6 +218,7 @@ schema.statics.findByPathAndViewer = async function(
 
   const baseQuery = useFindOne ? this.findOne({ path }) : this.find({ path });
   const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
+
   await addViewerCondition(queryBuilder, user, userGroups);
 
   return queryBuilder.query.exec();

+ 29 - 2
packages/app/src/server/routes/page.js

@@ -142,6 +142,7 @@ module.exports = function(crowi, app) {
 
   const Page = crowi.model('Page');
   const User = crowi.model('User');
+  const Bookmark = crowi.model('Bookmark');
   const PageTagRelation = crowi.model('PageTagRelation');
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
   const ShareLink = crowi.model('ShareLink');
@@ -282,6 +283,25 @@ module.exports = function(crowi, app) {
     renderVars.notFoundTargetPathOrId = pathOrId;
   }
 
+  async function addRenderVarsForIdenticalPage(renderVars, pages) {
+    const pageIds = pages.map(p => p._id);
+    const shortBodyMap = await crowi.pageService.shortBodiesMapByPageIds(pageIds);
+
+    const identicalPageDataList = await Promise.all(pages.map(async(page) => {
+      const bookmarkCount = await Bookmark.countByPageId(page._id);
+      page._doc.seenUserCount = (page.seenUsers && page.seenUsers.length) || 0;
+      return {
+        pageData: page,
+        pageMeta: {
+          bookmarkCount,
+        },
+      };
+    }));
+
+    renderVars.identicalPageDataList = identicalPageDataList;
+    renderVars.shortBodyMap = shortBodyMap;
+  }
+
   function addRenderVarsWhenNotCreatableOrForbidden(renderVars) {
     renderVars.isAlertHidden = true;
   }
@@ -613,8 +633,15 @@ module.exports = function(crowi, app) {
     const { redirectFrom } = req.query;
 
     if (pages.length >= 2) {
-      return res.render('layout-growi/identical-path-page-list', {
-        pages, redirectFrom,
+
+      const renderVars = {};
+
+      await addRenderVarsForIdenticalPage(renderVars, pages);
+
+      return res.render('layout-growi/identical-path-page', {
+        ...renderVars,
+        redirectFrom,
+        path,
       });
     }
 

+ 0 - 6
packages/app/src/server/views/layout-growi/identical-path-page-list.html

@@ -1,6 +0,0 @@
-{% extends 'base/layout.html' %}
-
-{% block content_main %}
-<div id="grw-fav-sticky-trigger" class="sticky-top"></div>
-<div id="identical-path-page-list"></div>
-{% endblock %}

+ 33 - 0
packages/app/src/server/views/layout-growi/identical-path-page.html

@@ -0,0 +1,33 @@
+{% extends 'base/layout.html' %}
+
+{% block content_main_before %}
+{% endblock %}
+
+
+{% block content_main %}
+  <div class="grw-container-convertible">
+
+    <div id="content-main" class="content-main d-flex"
+      data-path="{{ encodeURI(path) }}"
+      data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
+      data-slack-channels="{% if page %}{{ page.slackChannels }}{% endif %}"
+      data-page-is-not-creatable="true"
+      data-page-is-deleted="{% if page.isDeleted() %}true{% else %}false{% endif %}"
+      data-identical-path="true"
+    >
+      <div class="flex-grow-1 flex-basis-0 mw-0">
+        <div
+          id="identical-path-page"
+          data-identical-page-data-list="{{ identicalPageDataList|json }}"
+          data-shortody-map="{{ shortBodyMap|json }}"
+        ></div>
+      </div>
+      <div id="page-context"></div>
+    </div>
+
+  </div>
+{% endblock %}
+
+{% block content_footer %}
+  <div id="page-content-footer"></div>
+{% endblock %}

+ 55 - 46
packages/app/src/stores/context.tsx

@@ -11,122 +11,139 @@ import { TargetAndAncestors, NotFoundTargetPathOrId } from '../interfaces/page-l
 
 type Nullable<T> = T | null;
 
-export const useCurrentUser = (initialData?: IUser): SWRResponse<Nullable<IUser>, Error> => {
-  return useStaticSWR<Nullable<IUser>, Error>('currentUser', initialData ?? null);
+export const useCurrentUser = (initialData?: Nullable<IUser>): SWRResponse<Nullable<IUser>, Error> => {
+  return useStaticSWR<Nullable<IUser>, Error>('currentUser', initialData);
 };
 
 export const useRevisionId = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('revisionId', initialData ?? null);
+  return useStaticSWR<Nullable<any>, Error>('revisionId', initialData);
 };
 
 export const useCurrentPagePath = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
-  return useStaticSWR<Nullable<string>, Error>('currentPagePath', initialData ?? null);
+  return useStaticSWR<Nullable<string>, Error>('currentPagePath', initialData);
 };
 
-
 export const useCurrentPageId = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
-  return useStaticSWR<Nullable<string>, Error>('currentPageId', initialData ?? null);
+  return useStaticSWR<Nullable<string>, Error>('currentPageId', initialData);
 };
 
 export const useRevisionCreatedAt = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('revisionCreatedAt', initialData ?? null);
+  return useStaticSWR<Nullable<any>, Error>('revisionCreatedAt', initialData);
 };
 
 export const useCurrentCreatedAt = (initialData?: Nullable<Date>): SWRResponse<Nullable<Date>, Error> => {
-  return useStaticSWR<Nullable<Date>, Error>('createdAt', initialData ?? null);
+  return useStaticSWR<Nullable<Date>, Error>('createdAt', initialData);
 };
 
 export const useCurrentUpdatedAt = (initialData?: Nullable<Date>): SWRResponse<Nullable<Date>, Error> => {
-  return useStaticSWR<Nullable<Date>, Error>('updatedAt', initialData ?? null);
+  return useStaticSWR<Nullable<Date>, Error>('updatedAt', initialData);
 };
 
 export const useDeletedAt = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('deletedAt', initialData ?? null);
+  return useStaticSWR<Nullable<any>, Error>('deletedAt', initialData);
+};
+
+export const useIsIdenticalPath = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useStaticSWR<boolean, Error>('isIdenticalPath', initialData, { fallbackData: false });
+};
+
+export const useIsUserPage = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useStaticSWR<boolean, Error>('isUserPage', initialData, { fallbackData: false });
 };
 
-export const useIsUserPage = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('isUserPage', initialData ?? null);
+export const useIsTrashPage = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useStaticSWR<boolean, Error>('isTrashPage', initialData, { fallbackData: false });
 };
 
-export const useIsTrashPage = (initialData?: Nullable<boolean>): SWRResponse<Nullable<boolean>, Error> => {
-  return useStaticSWR<Nullable<boolean>, Error>('isTrashPage', initialData ?? null);
+export const useIsDeleted = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useStaticSWR<boolean, Error>('isDeleted', initialData, { fallbackData: false });
 };
 
-export const useIsDeleted = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('isDeleted', initialData ?? null);
+export const useIsDeletable = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useStaticSWR<boolean, Error>('isDeletable', initialData, { fallbackData: false });
 };
 
-export const useIsDeletable = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('isDeletable', initialData ?? null);
+export const useIsNotCreatable = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useStaticSWR<boolean, Error>('isNotCreatable', initialData, { fallbackData: false });
 };
 
-export const useIsNotCreatable = (initialData?: Nullable<boolean>): SWRResponse<Nullable<boolean>, Error> => {
-  return useStaticSWR<Nullable<boolean>, Error>('isNotCreatable', initialData ?? null);
+export const useIsAbleToDeleteCompletely = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useStaticSWR<boolean, Error>('isAbleToDeleteCompletely', initialData, { fallbackData: false });
 };
 
-export const useIsAbleToDeleteCompletely = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('isAbleToDeleteCompletely', initialData ?? null);
+export const useIsPageExist = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useStaticSWR<boolean, Error>('isPageExist', initialData, { fallbackData: false });
 };
 
-export const useIsPageExist = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('isPageExist', initialData ?? null);
+export const useIsForbidden = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useStaticSWR<boolean, Error>('isForbidden', initialData, { fallbackData: false });
 };
 
 export const usePageUser = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('pageUser', initialData ?? null);
+  return useStaticSWR<Nullable<any>, Error>('pageUser', initialData);
 };
 
 export const useHasChildren = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('hasChildren', initialData ?? null);
+  return useStaticSWR<Nullable<any>, Error>('hasChildren', initialData);
 };
 
 export const useTemplateTagData = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('templateTagData', initialData ?? null);
+  return useStaticSWR<Nullable<any>, Error>('templateTagData', initialData);
 };
 
 export const useShareLinksNumber = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('shareLinksNumber', initialData ?? null);
+  return useStaticSWR<Nullable<any>, Error>('shareLinksNumber', initialData);
 };
 
 export const useShareLinkId = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('shareLinkId', initialData ?? null);
+  return useStaticSWR<Nullable<any>, Error>('shareLinkId', initialData);
 };
 
 export const useRevisionIdHackmdSynced = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('revisionIdHackmdSynced', initialData ?? null);
+  return useStaticSWR<Nullable<any>, Error>('revisionIdHackmdSynced', initialData);
 };
 
 export const useLastUpdateUsername = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('lastUpdateUsername', initialData ?? null);
+  return useStaticSWR<Nullable<any>, Error>('lastUpdateUsername', initialData);
 };
 
 export const useDeleteUsername = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('deleteUsername', initialData ?? null);
+  return useStaticSWR<Nullable<any>, Error>('deleteUsername', initialData);
 };
 
 export const usePageIdOnHackmd = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('pageIdOnHackmd', initialData ?? null);
+  return useStaticSWR<Nullable<any>, Error>('pageIdOnHackmd', initialData);
 };
 
 export const useHasDraftOnHackmd = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('hasDraftOnHackmd', initialData ?? null);
+  return useStaticSWR<Nullable<any>, Error>('hasDraftOnHackmd', initialData);
 };
 
 export const useCreator = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('creator', initialData ?? null);
+  return useStaticSWR<Nullable<any>, Error>('creator', initialData);
 };
 
 export const useRevisionAuthor = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('revisionAuthor', initialData ?? null);
+  return useStaticSWR<Nullable<any>, Error>('revisionAuthor', initialData);
 };
 
 export const useSlackChannels = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('slackChannels', initialData ?? null);
+  return useStaticSWR<Nullable<any>, Error>('slackChannels', initialData);
 };
 
 export const useIsSearchPage = (initialData?: Nullable<any>) : SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('isSearchPage', initialData ?? null);
+  return useStaticSWR<Nullable<any>, Error>('isSearchPage', initialData);
 };
+
+export const useTargetAndAncestors = (initialData?: TargetAndAncestors): SWRResponse<TargetAndAncestors, Error> => {
+  return useStaticSWR<TargetAndAncestors, Error>('targetAndAncestors', initialData);
+};
+
+export const useNotFoundTargetPathOrId = (initialData?: Nullable<NotFoundTargetPathOrId>): SWRResponse<Nullable<NotFoundTargetPathOrId>, Error> => {
+  return useStaticSWR<Nullable<NotFoundTargetPathOrId>, Error>('notFoundTargetPathOrId', initialData);
+};
+
+
 /** **********************************************************
  *                     Computed contexts
  *********************************************************** */
@@ -164,11 +181,3 @@ export const useIsSharedUser = (): SWRResponse<boolean, Error> => {
     },
   );
 };
-
-export const useTargetAndAncestors = (initialData?: TargetAndAncestors): SWRResponse<TargetAndAncestors, Error> => {
-  return useStaticSWR<TargetAndAncestors, Error>('targetAndAncestors', initialData || null);
-};
-
-export const useNotFoundTargetPathOrId = (initialData?: NotFoundTargetPathOrId): SWRResponse<NotFoundTargetPathOrId, Error> => {
-  return useStaticSWR<NotFoundTargetPathOrId, Error>('notFoundTargetPathOrId', initialData || null);
-};

+ 1 - 4
packages/app/src/stores/editor.tsx

@@ -2,8 +2,5 @@ import { SWRResponse } from 'swr';
 import { useStaticSWR } from './use-static-swr';
 
 export const useIsSlackEnabled = (isEnabled?: boolean): SWRResponse<boolean, Error> => {
-  const initialData = false;
-  return (
-    useStaticSWR('isSlackEnabled', isEnabled || null, { fallbackData: initialData })
-  );
+  return useStaticSWR('isSlackEnabled', isEnabled, { fallbackData: false });
 };

+ 86 - 19
packages/app/src/stores/ui.tsx

@@ -10,8 +10,12 @@ import { SidebarContentsType } from '~/interfaces/ui';
 import loggerFactory from '~/utils/logger';
 
 import { useStaticSWR } from './use-static-swr';
-import { useCurrentPagePath, useIsEditable } from './context';
+import {
+  useCurrentPagePath, useIsEditable, useIsPageExist, useIsTrashPage, useIsUserPage,
+  useIsNotCreatable, useIsSharedUser, useNotFoundTargetPathOrId, useIsForbidden, useIsIdenticalPath,
+} from './context';
 import { IFocusable } from '~/client/interfaces/focusable';
+import { isSharedPage } from '^/../core/src/utils/page-path-utils';
 
 const logger = loggerFactory('growi:stores:ui');
 
@@ -37,7 +41,7 @@ export type EditorMode = typeof EditorMode[keyof typeof EditorMode];
  *                      for switching UI
  *********************************************************** */
 
-export const useIsMobile = (): SWRResponse<boolean|null, Error> => {
+export const useIsMobile = (): SWRResponse<boolean, Error> => {
   const key = isServer ? null : 'isMobile';
 
   let configuration;
@@ -48,7 +52,7 @@ export const useIsMobile = (): SWRResponse<boolean|null, Error> => {
     };
   }
 
-  return useStaticSWR(key, null, configuration);
+  return useStaticSWR<boolean, Error>(key, undefined, configuration);
 };
 
 const updateBodyClassesByEditorMode = (newEditorMode: EditorMode) => {
@@ -145,7 +149,7 @@ export const useEditorMode = (): SWRResponse<EditorMode, Error> => {
   };
 };
 
-export const useIsDeviceSmallerThanMd = (): SWRResponse<boolean|null, Error> => {
+export const useIsDeviceSmallerThanMd = (): SWRResponse<boolean, Error> => {
   const key: Key = isServer ? null : 'isDeviceSmallerThanMd';
 
   const { cache, mutate } = useSWRConfig();
@@ -169,7 +173,7 @@ export const useIsDeviceSmallerThanMd = (): SWRResponse<boolean|null, Error> =>
   return useStaticSWR(key);
 };
 
-export const useIsDeviceSmallerThanLg = (): SWRResponse<boolean|null, Error> => {
+export const useIsDeviceSmallerThanLg = (): SWRResponse<boolean, Error> => {
   const key: Key = isServer ? null : 'isDeviceSmallerThanLg';
 
   const { cache, mutate } = useSWRConfig();
@@ -194,23 +198,23 @@ export const useIsDeviceSmallerThanLg = (): SWRResponse<boolean|null, Error> =>
 };
 
 export const usePreferDrawerModeByUser = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useStaticSWR('preferDrawerModeByUser', initialData ?? null, { fallbackData: false });
+  return useStaticSWR('preferDrawerModeByUser', initialData, { fallbackData: false });
 };
 
 export const usePreferDrawerModeOnEditByUser = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useStaticSWR('preferDrawerModeOnEditByUser', initialData ?? null, { fallbackData: true });
+  return useStaticSWR('preferDrawerModeOnEditByUser', initialData, { fallbackData: true });
 };
 
 export const useSidebarCollapsed = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useStaticSWR('isSidebarCollapsed', initialData ?? null, { fallbackData: false });
+  return useStaticSWR('isSidebarCollapsed', initialData, { fallbackData: false });
 };
 
 export const useCurrentSidebarContents = (initialData?: SidebarContentsType): SWRResponse<SidebarContentsType, Error> => {
-  return useStaticSWR('sidebarContents', initialData ?? null, { fallbackData: SidebarContentsType.RECENT });
+  return useStaticSWR('sidebarContents', initialData, { fallbackData: SidebarContentsType.RECENT });
 };
 
 export const useCurrentProductNavWidth = (initialData?: number): SWRResponse<number, Error> => {
-  return useStaticSWR('productNavWidth', initialData ?? null, { fallbackData: 320 });
+  return useStaticSWR('productNavWidth', initialData, { fallbackData: 320 });
 };
 
 export const useDrawerMode = (): SWRResponse<boolean, Error> => {
@@ -241,13 +245,11 @@ export const useDrawerMode = (): SWRResponse<boolean, Error> => {
 };
 
 export const useDrawerOpened = (isOpened?: boolean): SWRResponse<boolean, Error> => {
-  const initialData = false;
-  return useStaticSWR('isDrawerOpened', isOpened || null, { fallbackData: initialData });
+  return useStaticSWR('isDrawerOpened', isOpened, { fallbackData: false });
 };
 
 export const useSidebarResizeDisabled = (isDisabled?: boolean): SWRResponse<boolean, Error> => {
-  const initialData = false;
-  return useStaticSWR('isSidebarResizeDisabled', isDisabled || null, { fallbackData: initialData });
+  return useStaticSWR('isSidebarResizeDisabled', isDisabled, { fallbackData: false });
 };
 
 type CreateModalStatus = {
@@ -261,7 +263,8 @@ type CreateModalStatusUtils = {
 }
 
 export const useCreateModalStatus = (status?: CreateModalStatus): SWRResponse<CreateModalStatus, Error> & CreateModalStatusUtils => {
-  const swrResponse = useStaticSWR<CreateModalStatus, Error>('modalStatus', status || null);
+  const initialData: CreateModalStatus = { isOpened: false };
+  const swrResponse = useStaticSWR<CreateModalStatus, Error>('modalStatus', status, { fallbackData: initialData });
 
   return {
     ...swrResponse,
@@ -294,17 +297,81 @@ export const useCreateModalPath = (): SWRResponse<string | null | undefined, Err
 
 
 export const useSelectedGrant = (initialData?: Nullable<number>): SWRResponse<Nullable<number>, Error> => {
-  return useStaticSWR<Nullable<number>, Error>('grant', initialData ?? null);
+  return useStaticSWR<Nullable<number>, Error>('grant', initialData);
 };
 
 export const useSelectedGrantGroupId = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
-  return useStaticSWR<Nullable<string>, Error>('grantGroupId', initialData ?? null);
+  return useStaticSWR<Nullable<string>, Error>('grantGroupId', initialData);
 };
 
 export const useSelectedGrantGroupName = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
-  return useStaticSWR<Nullable<string>, Error>('grantGroupName', initialData ?? null);
+  return useStaticSWR<Nullable<string>, Error>('grantGroupName', initialData);
 };
 
 export const useGlobalSearchFormRef = (initialData?: RefObject<IFocusable>): SWRResponse<RefObject<IFocusable>, Error> => {
-  return useStaticSWR('globalSearchTypeahead', initialData ?? null);
+  return useStaticSWR('globalSearchTypeahead', initialData);
+};
+
+export const useIsAbleToShowPageManagement = (): SWRResponse<boolean, Error> => {
+  const key = 'isAbleToShowPageManagement';
+  const { data: isPageExist } = useIsPageExist();
+  const { data: isTrashPage } = useIsTrashPage();
+  const { data: isSharedUser } = useIsSharedUser();
+
+  const includesUndefined = [isPageExist, isTrashPage, isSharedUser].some(v => v === undefined);
+
+  return useSWRImmutable(
+    includesUndefined ? null : key,
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    () => isPageExist! && !isTrashPage && !isSharedUser,
+  );
+};
+
+export const useIsAbleToShowTagLabel = (): SWRResponse<boolean, Error> => {
+  const key = 'isAbleToShowTagLabel';
+  const { data: isUserPage } = useIsUserPage();
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { data: isIdenticalPath } = useIsIdenticalPath();
+  const { data: notFoundTargetPathOrId } = useNotFoundTargetPathOrId();
+  const { data: editorMode } = useEditorMode();
+
+  const includesUndefined = [isUserPage, currentPagePath, isIdenticalPath, notFoundTargetPathOrId, editorMode].some(v => v === undefined);
+
+  const isViewMode = editorMode === EditorMode.View;
+  const isNotFoundPage = notFoundTargetPathOrId != null;
+
+  return useSWRImmutable(
+    includesUndefined ? null : key,
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    () => !isUserPage && !isSharedPage(currentPagePath!) && !isIdenticalPath && !(isViewMode && isNotFoundPage),
+  );
+};
+
+export const useIsAbleToShowPageEditorModeManager = (): SWRResponse<boolean, Error> => {
+  const key = 'isAbleToShowPageEditorModeManager';
+  const { data: isNotCreatable } = useIsNotCreatable();
+  const { data: isForbidden } = useIsForbidden();
+  const { data: isTrashPage } = useIsTrashPage();
+  const { data: isSharedUser } = useIsSharedUser();
+
+  const includesUndefined = [isNotCreatable, isForbidden, isTrashPage, isSharedUser].some(v => v === undefined);
+
+  return useSWRImmutable(
+    includesUndefined ? null : key,
+    () => !isNotCreatable && !isForbidden && !isTrashPage && !isSharedUser,
+  );
+};
+
+export const useIsAbleToShowPageAuthors = (): SWRResponse<boolean, Error> => {
+  const key = 'isAbleToShowPageAuthors';
+  const { data: isPageExist } = useIsPageExist();
+  const { data: isUserPage } = useIsUserPage();
+
+  const includesUndefined = [isPageExist, isUserPage].some(v => v === undefined);
+
+  return useSWRImmutable(
+    includesUndefined ? null : key,
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    () => isPageExist! && !isUserPage,
+  );
 };

+ 5 - 5
packages/app/src/stores/use-static-swr.tsx

@@ -6,14 +6,14 @@ import useSWRImmutable from 'swr/immutable';
 
 
 export function useStaticSWR<Data, Error>(key: Key): SWRResponse<Data, Error>;
-export function useStaticSWR<Data, Error>(key: Key, data: Data | null): SWRResponse<Data, Error>;
-export function useStaticSWR<Data, Error>(key: Key, data: Data | null,
+export function useStaticSWR<Data, Error>(key: Key, data: Data | undefined): SWRResponse<Data, Error>;
+export function useStaticSWR<Data, Error>(key: Key, data: Data | undefined,
   configuration: SWRConfiguration<Data, Error> | undefined): SWRResponse<Data, Error>;
 
 export function useStaticSWR<Data, Error>(
     ...args: readonly [Key]
-    | readonly [Key, Data | null]
-    | readonly [Key, Data | null, SWRConfiguration<Data, Error> | undefined]
+    | readonly [Key, Data | undefined]
+    | readonly [Key, Data | undefined, SWRConfiguration<Data, Error> | undefined]
 ): SWRResponse<Data, Error> {
   const [key, data, configuration] = args;
 
@@ -22,7 +22,7 @@ export function useStaticSWR<Data, Error>(
   const swrResponse = useSWRImmutable(key, null, configuration);
 
   // mutate
-  if (data != null) {
+  if (data !== undefined) {
     const { mutate } = swrResponse;
     mutate(data);
   }

+ 5 - 0
packages/app/src/styles/_page_list.scss

@@ -25,6 +25,11 @@ body .page-list {
       width: 16px;
       height: 16px;
       vertical-align: text-bottom;
+
+      &.picture-md {
+        width: 20px;
+        height: 20px;
+      }
     }
 
     .page-list-meta {

+ 0 - 4
packages/app/src/styles/_search.scss

@@ -218,10 +218,6 @@
     }
   }
   .search-item-text {
-    .picture-sm {
-      width: 20px;
-      height: 20px;
-    }
     .item-meta {
       .top-label {
         display: none; // not show top label in search result list

+ 9 - 8
packages/ui/src/components/PagePath/PageListMeta.jsx

@@ -9,39 +9,39 @@ const { checkTemplatePath } = templateChecker;
 export class PageListMeta extends React.Component {
 
   render() {
-    const { page } = this.props;
+    const { page, shouldSpaceOutIcon } = this.props;
 
     // top check
     let topLabel;
     if (isTopPage(page.path)) {
-      topLabel = <span className="badge badge-info meta-icon top-label">TOP</span>;
+      topLabel = <span className={`badge badge-info ${shouldSpaceOutIcon ? 'mr-3' : ''} top-label`}>TOP</span>;
     }
 
     // template check
     let templateLabel;
     if (checkTemplatePath(page.path)) {
-      templateLabel = <span className="badge badge-info meta-icon">TMPL</span>;
+      templateLabel = <span className={`badge badge-info ${shouldSpaceOutIcon ? 'mr-3' : ''}`}>TMPL</span>;
     }
 
     let commentCount;
     if (page.commentCount != null && page.commentCount > 0) {
-      commentCount = <span className="meta-icon"><i className="icon-bubble" />{page.commentCount}</span>;
+      commentCount = <span className={`${shouldSpaceOutIcon ? 'mr-3' : ''}`}><i className="icon-bubble" />{page.commentCount}</span>;
     }
 
     let likerCount;
     if (page.liker != null && page.liker.length > 0) {
-      likerCount = <span className="meta-icon"><i className="fa fa-heart-o" />{page.liker.length}</span>;
+      likerCount = <span className={`${shouldSpaceOutIcon ? 'mr-3' : ''}`}><i className="fa fa-heart-o" />{page.liker.length}</span>;
     }
 
     let locked;
     if (page.grant !== 1) {
-      locked = <span className="meta-icon"><i className="icon-lock" /></span>;
+      locked = <span className={`${shouldSpaceOutIcon ? 'mr-3' : ''}`}><i className="icon-lock" /></span>;
     }
 
     let seenUserCount;
     if (page.seenUserCount > 0) {
       seenUserCount = (
-        <span className="meta-icon">
+        <span className={`${shouldSpaceOutIcon ? 'mr-3' : ''}`}>
           <i className="footstamp-icon"><FootstampIcon /></i>
           {page.seenUsers.length}
         </span>
@@ -50,7 +50,7 @@ export class PageListMeta extends React.Component {
 
     let bookmarkCount;
     if (this.props.bookmarkCount > 0) {
-      bookmarkCount = <span className="meta-icon"><i className="fa fa-bookmark-o" />{this.props.bookmarkCount}</span>;
+      bookmarkCount = <span className={`${shouldSpaceOutIcon ? 'mr-3' : ''}`}><i className="fa fa-bookmark-o" />{this.props.bookmarkCount}</span>;
     }
 
     return (
@@ -71,4 +71,5 @@ export class PageListMeta extends React.Component {
 PageListMeta.propTypes = {
   page: PropTypes.object.isRequired,
   bookmarkCount: PropTypes.number,
+  shouldSpaceOutIcon: PropTypes.bool,
 };