Shun Miyazawa пре 4 година
родитељ
комит
775fbc1ce3

+ 2 - 0
packages/app/resource/locales/en_US/translation.json

@@ -100,6 +100,7 @@
   "Connected": "Connected",
   "Connected": "Connected",
   "Show": "Show",
   "Show": "Show",
   "Hide": "Hide",
   "Hide": "Hide",
+  "Loading": "Loading...",
   "Disclose E-mail": "Disclose E-mail",
   "Disclose E-mail": "Disclose E-mail",
   "page exists": "this page already exists",
   "page exists": "this page already exists",
   "Error occurred": "Error occurred",
   "Error occurred": "Error occurred",
@@ -148,6 +149,7 @@
   "Shareable link": "Shareable link",
   "Shareable link": "Shareable link",
   "The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
   "The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
   "Add tags for this page": "Add tags for this page",
   "Add tags for this page": "Add tags for this page",
+  "popular_tags": "Popular tags",
   "Check All tags": "check all tags",
   "Check All tags": "check all tags",
   "You have no tag, You can set tags on pages": "You have no tag, You can set tags on pages",
   "You have no tag, You can set tags on pages": "You have no tag, You can set tags on pages",
   "Show latest": "Show latest",
   "Show latest": "Show latest",

+ 3 - 1
packages/app/resource/locales/ja_JP/translation.json

@@ -100,6 +100,7 @@
   "Connected": "接続されています",
   "Connected": "接続されています",
   "Show": "公開",
   "Show": "公開",
   "Hide": "非公開",
   "Hide": "非公開",
+  "Loading": "読み込み中...",
   "Disclose E-mail": "メールアドレスの公開",
   "Disclose E-mail": "メールアドレスの公開",
   "page exists": "このページはすでに存在しています",
   "page exists": "このページはすでに存在しています",
   "Error occurred": "エラーが発生しました",
   "Error occurred": "エラーが発生しました",
@@ -147,7 +148,8 @@
   "Shareable link": "このページの共有用URL",
   "Shareable link": "このページの共有用URL",
   "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
   "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
   "Add tags for this page": "タグを付ける",
   "Add tags for this page": "タグを付ける",
-  "Check All tags": "全てのタグをチェックする",
+  "popular_tags": "人気のタグ",
+  "Check All tags": "全てのタグを見る",
   "You have no tag, You can set tags on pages": "使用中のタグがありません",
   "You have no tag, You can set tags on pages": "使用中のタグがありません",
   "Show latest": "最新のページを表示",
   "Show latest": "最新のページを表示",
   "Load latest": "最新版を読み込む",
   "Load latest": "最新版を読み込む",

+ 2 - 0
packages/app/resource/locales/zh_CN/translation.json

@@ -107,6 +107,7 @@
 	"Connected": "Connected",
 	"Connected": "Connected",
 	"Show": "显示",
 	"Show": "显示",
 	"Hide": "隐藏",
 	"Hide": "隐藏",
+  "Loading": "加载...",
 	"Reset": "重置",
 	"Reset": "重置",
 	"Disclose E-mail": "显示邮箱",
 	"Disclose E-mail": "显示邮箱",
 	"page exists": "页面已存在",
 	"page exists": "页面已存在",
@@ -156,6 +157,7 @@
 	"Shareable link": "可分享链接",
 	"Shareable link": "可分享链接",
 	"The whitelist of registration permission E-mail address": "注册许可电子邮件地址的白名单",
 	"The whitelist of registration permission E-mail address": "注册许可电子邮件地址的白名单",
 	"Add tags for this page": "添加标签",
 	"Add tags for this page": "添加标签",
+  "popular_tags": "流行标签",
   "Check All tags": "检查所有标签",
   "Check All tags": "检查所有标签",
 	"You have no tag, You can set tags on pages": "你没有标签,可以在页面上设置标签",
 	"You have no tag, You can set tags on pages": "你没有标签,可以在页面上设置标签",
 	"Show latest": "显示最新",
 	"Show latest": "显示最新",

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

@@ -1,28 +1,26 @@
 import React from 'react';
 import React from 'react';
-import ReactDOM from 'react-dom';
-import { Provider } from 'unstated';
-import { I18nextProvider } from 'react-i18next';
+
 import { DndProvider } from 'react-dnd';
 import { DndProvider } from 'react-dnd';
 import { HTML5Backend } from 'react-dnd-html5-backend';
 import { HTML5Backend } from 'react-dnd-html5-backend';
-
+import ReactDOM from 'react-dom';
+import { I18nextProvider } from 'react-i18next';
 import { SWRConfig } from 'swr';
 import { SWRConfig } from 'swr';
+import { Provider } from 'unstated';
 
 
+import CommentContainer from '~/client/services/CommentContainer';
+import ContextExtractor from '~/client/services/ContextExtractor';
+import EditorContainer from '~/client/services/EditorContainer';
+import PageContainer from '~/client/services/PageContainer';
+import PageHistoryContainer from '~/client/services/PageHistoryContainer';
+import PersonalContainer from '~/client/services/PersonalContainer';
+import RevisionComparerContainer from '~/client/services/RevisionComparerContainer';
+import TagContainer from '~/client/services/TagContainer';
+import IdenticalPathPage from '~/components/IdenticalPathPage';
+import { PrivateLegacyPages } from '~/components/PrivateLegacyPages';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { swrGlobalConfiguration } from '~/utils/swr-utils';
 import { swrGlobalConfiguration } from '~/utils/swr-utils';
 
 
-import InAppNotificationPage from '../components/InAppNotification/InAppNotificationPage';
 import ErrorBoundary from '../components/ErrorBoudary';
 import ErrorBoundary from '../components/ErrorBoudary';
-import Sidebar from '../components/Sidebar';
-import { SearchPage } from '../components/SearchPage';
-import TagsList from '../components/TagsList';
-import DisplaySwitcher from '../components/Page/DisplaySwitcher';
-import { defaultEditorOptions, defaultPreviewOptions } from '../components/PageEditor/OptionsSelector';
-import Page from '../components/Page';
-import PageContentFooter from '../components/PageContentFooter';
-import PageComment from '../components/PageComment';
-import PageTimeline from '../components/PageTimeline';
-import CommentEditorLazyRenderer from '../components/PageComment/CommentEditorLazyRenderer';
-import ShareLinkAlert from '../components/Page/ShareLinkAlert';
 import RedirectedAlert from '../components/Page/RedirectedAlert';
 import RedirectedAlert from '../components/Page/RedirectedAlert';
 import TrashPageList from '../components/TrashPageList';
 import TrashPageList from '../components/TrashPageList';
 import TrashPageAlert from '../components/Page/TrashPageAlert';
 import TrashPageAlert from '../components/Page/TrashPageAlert';
@@ -35,23 +33,25 @@ import RecentlyCreatedIcon from '../components/Icons/RecentlyCreatedIcon';
 import MyDraftList from '../components/MyDraftList/MyDraftList';
 import MyDraftList from '../components/MyDraftList/MyDraftList';
 import BookmarkList from '../components/PageList/BookmarkList';
 import BookmarkList from '../components/PageList/BookmarkList';
 import Fab from '../components/Fab';
 import Fab from '../components/Fab';
+import InAppNotificationPage from '../components/InAppNotification/InAppNotificationPage';
 import PersonalSettings from '../components/Me/PersonalSettings';
 import PersonalSettings from '../components/Me/PersonalSettings';
 import GrowiContextualSubNavigation from '../components/Navbar/GrowiContextualSubNavigation';
 import GrowiContextualSubNavigation from '../components/Navbar/GrowiContextualSubNavigation';
 import GrowiSubNavigationSwitcher from '../components/Navbar/GrowiSubNavigationSwitcher';
 import GrowiSubNavigationSwitcher from '../components/Navbar/GrowiSubNavigationSwitcher';
-import IdenticalPathPage from '~/components/IdenticalPathPage';
-
-import ContextExtractor from '~/client/services/ContextExtractor';
-import PageContainer from '~/client/services/PageContainer';
-import PageHistoryContainer from '~/client/services/PageHistoryContainer';
-import RevisionComparerContainer from '~/client/services/RevisionComparerContainer';
-import CommentContainer from '~/client/services/CommentContainer';
-import EditorContainer from '~/client/services/EditorContainer';
-import TagContainer from '~/client/services/TagContainer';
-import PersonalContainer from '~/client/services/PersonalContainer';
+import Page from '../components/Page';
+import DisplaySwitcher from '../components/Page/DisplaySwitcher';
+import ShareLinkAlert from '../components/Page/ShareLinkAlert';
+import PageComment from '../components/PageComment';
+import CommentEditorLazyRenderer from '../components/PageComment/CommentEditorLazyRenderer';
+import PageContentFooter from '../components/PageContentFooter';
+import { defaultEditorOptions, defaultPreviewOptions } from '../components/PageEditor/OptionsSelector';
+import PageTimeline from '../components/PageTimeline';
+import { SearchPage } from '../components/SearchPage';
+import Sidebar from '../components/Sidebar';
+import TagPage from '../components/TagPage';
 
 
 import { appContainer, componentMappings } from './base';
 import { appContainer, componentMappings } from './base';
 import { toastError } from './util/apiNotification';
 import { toastError } from './util/apiNotification';
-import { PrivateLegacyPages } from '~/components/PrivateLegacyPages';
+
 
 
 const logger = loggerFactory('growi:cli:app');
 const logger = loggerFactory('growi:cli:app');
 
 
@@ -90,7 +90,7 @@ Object.assign(componentMappings, {
   'identical-path-page': <IdenticalPathPage />,
   'identical-path-page': <IdenticalPathPage />,
 
 
   // 'revision-history': <PageHistory pageId={pageId} />,
   // 'revision-history': <PageHistory pageId={pageId} />,
-  'tags-page': <TagsList crowi={appContainer} />,
+  'tags-page': <TagPage />,
 
 
   'grw-page-status-alert-container': <PageStatusAlert />,
   'grw-page-status-alert-container': <PageStatusAlert />,
 
 

+ 4 - 0
packages/app/src/components/InAppNotification/InAppNotificationElm.tsx

@@ -103,6 +103,10 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
       actionMsg = 'renamed';
       actionMsg = 'renamed';
       actionIcon = 'icon-action-redo';
       actionIcon = 'icon-action-redo';
       break;
       break;
+    case 'PAGE_DUPLICATE':
+      actionMsg = 'duplicated';
+      actionIcon = 'icon-docs';
+      break;
     case 'PAGE_DELETE':
     case 'PAGE_DELETE':
       actionMsg = 'deleted';
       actionMsg = 'deleted';
       actionIcon = 'icon-trash';
       actionIcon = 'icon-trash';

+ 1 - 1
packages/app/src/components/Sidebar/SidebarNav.tsx

@@ -86,7 +86,7 @@ const SidebarNav: FC<Props> = (props: Props) => {
         <PrimaryItem contents={SidebarContentsType.RECENT} label="Recent Changes" iconName="update" onItemSelected={onItemSelected} />
         <PrimaryItem contents={SidebarContentsType.RECENT} label="Recent Changes" iconName="update" onItemSelected={onItemSelected} />
         {/* <PrimaryItem id="tag" label="Tags" iconName="icon-tag" /> */}
         {/* <PrimaryItem id="tag" label="Tags" iconName="icon-tag" /> */}
         {/* <PrimaryItem id="favorite" label="Favorite" iconName="fa fa-bookmark-o" /> */}
         {/* <PrimaryItem id="favorite" label="Favorite" iconName="fa fa-bookmark-o" /> */}
-        <PrimaryItem contents={SidebarContentsType.TAG} label="Tags" iconName="tag" onItemSelected={onItemSelected} />
+        <PrimaryItem contents={SidebarContentsType.TAG} label="Tags" iconName="local_offer" onItemSelected={onItemSelected} />
         {/* <PrimaryItem id="favorite" label="Favorite" iconName="icon-star" /> */}
         {/* <PrimaryItem id="favorite" label="Favorite" iconName="icon-star" /> */}
         {/* eslint-enable max-len */}
         {/* eslint-enable max-len */}
       </div>
       </div>

+ 54 - 16
packages/app/src/components/Sidebar/Tag.tsx

@@ -1,43 +1,81 @@
-import React, { FC, useState, useEffect } from 'react';
+import React, { FC, useState, useCallback } from 'react';
 
 
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
-import TagsList from '../TagsList';
+import { ITagCountHasId } from '~/interfaces/tag';
+import { useSWRxTagsList } from '~/stores/tag';
+
+import TagCloudBox from '../TagCloudBox';
+import TagList from '../TagList';
+
+
+const PAGING_LIMIT = 10;
 
 
 const Tag: FC = () => {
 const Tag: FC = () => {
+  const [activePage, setActivePage] = useState<number>(1);
+  const [offset, setOffset] = useState<number>(0);
+
+  const { data: tagDataList, mutate: mutateTagDataList, error } = useSWRxTagsList(PAGING_LIMIT, offset);
+  const tagData: ITagCountHasId[] = tagDataList?.data || [];
+  const totalCount: number = tagDataList?.totalCount || 0;
+  const isLoading = tagDataList === undefined && error == null;
+
   const { t } = useTranslation('');
   const { t } = useTranslation('');
-  const [isOnReload, setIsOnReload] = useState<boolean>(false);
 
 
-  useEffect(() => {
-    setIsOnReload(false);
-  }, [isOnReload]);
+  const setOffsetByPageNumber = useCallback((selectedPageNumber: number) => {
+    setActivePage(selectedPageNumber);
+    setOffset((selectedPageNumber - 1) * PAGING_LIMIT);
+  }, []);
+
+  const onReload = useCallback(() => {
+    mutateTagDataList();
+  }, [mutateTagDataList]);
 
 
+  // todo: adjust design by XD
   return (
   return (
-    <div data-testid="grw-sidebar-content-tags">
-      <div className="grw-sidebar-content-header p-3 d-flex">
+    <div className="grw-container-convertible px-4 mb-5 pb-5" data-testid="grw-sidebar-content-tags">
+      <div className="grw-sidebar-content-header py-3 d-flex">
         <h3 className="mb-0">{t('Tags')}</h3>
         <h3 className="mb-0">{t('Tags')}</h3>
         <button
         <button
           type="button"
           type="button"
           className="btn btn-sm ml-auto grw-btn-reload-rc"
           className="btn btn-sm ml-auto grw-btn-reload-rc"
-          onClick={() => {
-            setIsOnReload(true);
-          }}
+          onClick={onReload}
         >
         >
           <i className="icon icon-reload"></i>
           <i className="icon icon-reload"></i>
         </button>
         </button>
       </div>
       </div>
-      <div className="d-flex justify-content-center">
+      <h2 className="my-3">{t('popular_tags')}</h2>
+
+      <div className="px-3 text-center">
+        <TagCloudBox tags={tagData} />
+      </div>
+
+      <div className="d-flex justify-content-center my-5">
         <button
         <button
-          className="btn btn-primary my-4"
+          className="btn btn-primary rounded px-5"
           type="button"
           type="button"
           onClick={() => { window.location.href = '/tags' }}
           onClick={() => { window.location.href = '/tags' }}
         >
         >
           {t('Check All tags')}
           {t('Check All tags')}
         </button>
         </button>
       </div>
       </div>
-      <div className="grw-container-convertible mb-5 pb-5">
-        <TagsList isOnReload={isOnReload} />
-      </div>
+
+      { isLoading
+        ? (
+          <div className="text-muted text-center">
+            <i className="fa fa-2x fa-spinner fa-pulse mt-3"></i>
+          </div>
+        )
+        : (
+          <TagList
+            tagData={tagData}
+            totalTags={totalCount}
+            activePage={activePage}
+            onChangePage={setOffsetByPageNumber}
+            pagingLimit={PAGING_LIMIT}
+          />
+        )
+      }
     </div>
     </div>
   );
   );
 
 

+ 32 - 15
packages/app/src/components/TagCloudBox.tsx

@@ -1,34 +1,51 @@
-import React, { FC } from 'react';
-
+import React, { FC, memo } from 'react';
 import { TagCloud } from 'react-tagcloud';
 import { TagCloud } from 'react-tagcloud';
-
-import { ITagHasCount } from '~/interfaces/tag';
+import { ITagCountHasId } from '~/interfaces/tag';
 
 
 type Props = {
 type Props = {
-  tags: ITagHasCount[],
+  tags:ITagCountHasId[],
   minSize?: number,
   minSize?: number,
   maxSize?: number,
   maxSize?: number,
-}
+  maxTagTextLength?: number,
+  isDisableRandomColor?: boolean,
+};
+
+const defaultProps = {
+  isDisableRandomColor: true,
+};
+
+const MIN_FONT_SIZE = 10;
+const MAX_FONT_SIZE = 24;
+const MAX_TAG_TEXT_LENGTH = 8;
 
 
-const MIN_FONT_SIZE = 12;
-const MAX_FONT_SIZE = 36;
+const TagCloudBox: FC<Props> = memo((props:(Props & typeof defaultProps)) => {
+  const {
+    tags, minSize, maxSize, isDisableRandomColor,
+  } = props;
+  const maxTagTextLength: number = props.maxTagTextLength ?? MAX_TAG_TEXT_LENGTH;
 
 
-const TagCloudBox: FC<Props> = (props:Props) => {
   return (
   return (
     <>
     <>
       <TagCloud
       <TagCloud
-        minSize={props.minSize || MIN_FONT_SIZE}
-        maxSize={props.maxSize || MAX_FONT_SIZE}
-        tags={props.tags.map((tag) => {
-          return { value: tag.name, count: tag.count };
+        minSize={minSize ?? MIN_FONT_SIZE}
+        maxSize={maxSize ?? MAX_FONT_SIZE}
+        tags={tags.map((tag:ITagCountHasId) => {
+          return {
+            // text truncation
+            value: (tag.name).length > maxTagTextLength ? `${(tag.name).slice(0, maxTagTextLength)}...` : tag.name,
+            count: tag.count,
+          };
         })}
         })}
+        disableRandomColor={isDisableRandomColor}
         style={{ cursor: 'pointer' }}
         style={{ cursor: 'pointer' }}
-        className="simple-cloud"
+        className="simple-cloud text-secondary"
         onClick={(target) => { window.location.href = `/_search?q=tag:${encodeURIComponent(target.value)}` }}
         onClick={(target) => { window.location.href = `/_search?q=tag:${encodeURIComponent(target.value)}` }}
       />
       />
     </>
     </>
   );
   );
 
 
-};
+});
+
+TagCloudBox.defaultProps = defaultProps;
 
 
 export default TagCloudBox;
 export default TagCloudBox;

+ 76 - 0
packages/app/src/components/TagList.tsx

@@ -0,0 +1,76 @@
+import React, {
+  FC, useCallback,
+} from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { ITagCountHasId } from '~/interfaces/tag';
+
+import PaginationWrapper from './PaginationWrapper';
+
+type TagListProps = {
+  tagData: ITagCountHasId[],
+  totalTags: number,
+  activePage: number,
+  onChangePage?: (selectedPageNumber: number) => void,
+  pagingLimit: number,
+  isPaginationShown?: boolean,
+}
+
+const defaultProps = {
+  isPaginationShown: true,
+};
+
+const TagList: FC<TagListProps> = (props:(TagListProps & typeof defaultProps)) => {
+  const {
+    tagData, totalTags, activePage, onChangePage, pagingLimit, isPaginationShown,
+  } = props;
+  const isTagExist: boolean = tagData.length > 0;
+  const { t } = useTranslation('');
+
+  const generateTagList = useCallback((tagData) => {
+    return tagData.map((tag:ITagCountHasId, index:number) => {
+      const tagListClasses: string = index === 0 ? 'list-group-item d-flex' : 'list-group-item d-flex border-top-0';
+
+      return (
+        <a
+          key={tag._id}
+          href={`/_search?q=tag:${encodeURIComponent(tag.name)}`}
+          className={tagListClasses}
+        >
+          <div className="text-truncate">{tag.name}</div>
+          <div className="ml-4 my-auto py-1 px-2 list-tag-count badge badge-secondary text-white">{tag.count}</div>
+        </a>
+      );
+    });
+  }, []);
+
+  if (!isTagExist) {
+    return <h3>{ t('You have no tag, You can set tags on pages') }</h3>;
+  }
+
+  return (
+    <>
+      <ul className="list-group text-left mb-4">
+        {generateTagList(tagData)}
+      </ul>
+      {isPaginationShown
+      && (
+        <PaginationWrapper
+          activePage={activePage}
+          changePage={onChangePage}
+          totalItemsCount={totalTags}
+          pagingLimit={pagingLimit}
+          align="center"
+          size="md"
+        />
+      )
+      }
+    </>
+  );
+
+};
+
+TagList.defaultProps = defaultProps;
+
+export default TagList;

+ 59 - 0
packages/app/src/components/TagPage.tsx

@@ -0,0 +1,59 @@
+import React, { FC, useState, useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { ITagCountHasId } from '~/interfaces/tag';
+import { useSWRxTagsList } from '~/stores/tag';
+
+import TagCloudBox from './TagCloudBox';
+import TagList from './TagList';
+
+const PAGING_LIMIT = 10;
+
+const TagPage: FC = () => {
+  const [activePage, setActivePage] = useState<number>(1);
+  const [offset, setOffset] = useState<number>(0);
+
+  const { data: tagDataList, error } = useSWRxTagsList(PAGING_LIMIT, offset);
+  const tagData: ITagCountHasId[] = tagDataList?.data || [];
+  const totalCount: number = tagDataList?.totalCount || 0;
+  const isLoading = tagDataList === undefined && error == null;
+
+  const { t } = useTranslation('');
+
+  const setOffsetByPageNumber = useCallback((selectedPageNumber: number) => {
+    setActivePage(selectedPageNumber);
+    setOffset((selectedPageNumber - 1) * PAGING_LIMIT);
+  }, []);
+
+  // todo: adjust margin and redesign tags page
+  return (
+    <div className="grw-container-convertible mb-5 pb-5">
+      <h2 className="my-3">{`${t('Tags')}(${totalCount})`}</h2>
+      <div className="px-3 mb-5 text-center">
+        <TagCloudBox tags={tagData} minSize={20} />
+      </div>
+      { isLoading
+        ? (
+          <div className="text-muted text-center">
+            <i className="fa fa-2x fa-spinner fa-pulse mt-3"></i>
+          </div>
+        )
+        : (
+          <div data-testid="grw-tags-list">
+            <TagList
+              tagData={tagData}
+              totalTags={totalCount}
+              activePage={activePage}
+              onChangePage={setOffsetByPageNumber}
+              pagingLimit={PAGING_LIMIT}
+            />
+          </div>
+        )
+      }
+    </div>
+  );
+
+};
+
+export default TagPage;

+ 0 - 85
packages/app/src/components/TagsList.tsx

@@ -1,85 +0,0 @@
-import React, { FC, useEffect, useState } from 'react';
-
-import { useTranslation } from 'react-i18next';
-
-import { useSWRxTagsList } from '~/stores/tag';
-
-import PaginationWrapper from './PaginationWrapper';
-import TagCloudBox from './TagCloudBox';
-
-
-const PAGING_LIMIT = 10;
-
-type Props = {
-  isOnReload: boolean
-}
-
-const TagsList: FC<Props> = (props: Props) => {
-  const { t } = useTranslation();
-
-  const [activePage, setActivePage] = useState<number>(1);
-  const [pagingOffset, setPagingOffset] = useState<number>(0);
-
-  const { data: tagsList, error, mutate } = useSWRxTagsList(PAGING_LIMIT, pagingOffset);
-
-  const handlePage = (selectedPageNumber: number) => {
-    setActivePage(selectedPageNumber);
-    setPagingOffset((selectedPageNumber - 1) * PAGING_LIMIT);
-  };
-
-  useEffect(() => {
-    if (props.isOnReload) {
-      mutate();
-    }
-  }, [mutate, props.isOnReload]);
-
-  const isLoading = tagsList === undefined && error == null;
-  if (isLoading) {
-    return (
-      <div className="text-muted text-center">
-        <i className="fa fa-2x fa-spinner fa-pulse mt-3"></i>
-      </div>
-    );
-  }
-
-  return (
-    <div data-testid="grw-tags-list">
-      <header className="py-0">
-        <h1 className="title text-center mt-5 mb-3 font-weight-bold">{`${t('Tags')}(${tagsList?.totalCount || 0})`}</h1>
-      </header>
-      <div className="row text-center">
-        <div className="col-12 mb-5 px-5">
-          <TagCloudBox tags={tagsList?.data || []} minSize={20} />
-        </div>
-        <div className="col-12 tag-list mb-4">
-          <ul className="list-group text-left">
-            {
-              tagsList?.data != null && tagsList.data.length > 0
-                ? tagsList.data.map((tag) => {
-                  return (
-                    <a key={tag.name} href={`/_search?q=tag:${tag.name}`} className="list-group-item">
-                      <i className="icon-tag mr-2"></i>{tag.name}
-                      <span className="ml-4 list-tag-count badge badge-secondary text-muted">{tag.count}</span>
-                    </a>
-                  );
-                })
-                : <h3>{ t('You have no tag, You can set tags on pages') }</h3>
-            }
-          </ul>
-        </div>
-        <div className="col-12 tag-list-pagination">
-          <PaginationWrapper
-            activePage={activePage}
-            changePage={handlePage}
-            totalItemsCount={tagsList?.totalCount || 0}
-            pagingLimit={PAGING_LIMIT}
-            align="center"
-            size="md"
-          />
-        </div>
-      </div>
-    </div>
-  );
-};
-
-export default TagsList;

+ 6 - 2
packages/app/src/interfaces/tag.ts

@@ -1,9 +1,13 @@
+import { HasObjectId } from './has-object-id';
+
 export type ITag = {
 export type ITag = {
   name: string,
   name: string,
   createdAt: Date;
   createdAt: Date;
 }
 }
 
 
-export type ITagHasCount = ITag & { count: number }
+export type ITagCount = Omit<ITag, 'createdAt'> & {count: number}
+
+export type ITagCountHasId = ITagCount & HasObjectId
 
 
 export type ITagsSearchApiv1Result = {
 export type ITagsSearchApiv1Result = {
   ok: boolean,
   ok: boolean,
@@ -12,6 +16,6 @@ export type ITagsSearchApiv1Result = {
 
 
 export type ITagsListApiv1Result = {
 export type ITagsListApiv1Result = {
   ok: boolean,
   ok: boolean,
-  data: ITagHasCount[],
+  data: ITagCountHasId[],
   totalCount: number,
   totalCount: number,
 }
 }

+ 15 - 4
packages/app/src/server/service/page.ts

@@ -32,7 +32,6 @@ import { serializePageSecurely } from '../models/serializers/page-serializer';
 import Subscription from '../models/subscription';
 import Subscription from '../models/subscription';
 import ActivityDefine from '../util/activityDefine';
 import ActivityDefine from '../util/activityDefine';
 
 
-
 const debug = require('debug')('growi:services:page');
 const debug = require('debug')('growi:services:page');
 
 
 const logger = loggerFactory('growi:services:page');
 const logger = loggerFactory('growi:services:page');
@@ -175,10 +174,10 @@ class PageService {
       }
       }
     });
     });
 
 
-    // revert
-    this.pageEvent.on('revert', async(page, user) => {
+    // duplicate
+    this.pageEvent.on('duplicate', async(page, user) => {
       try {
       try {
-        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_REVERT);
+        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_DUPLICATE);
       }
       }
       catch (err) {
       catch (err) {
         logger.error(err);
         logger.error(err);
@@ -205,6 +204,16 @@ class PageService {
       }
       }
     });
     });
 
 
+    // revert
+    this.pageEvent.on('revert', async(page, user) => {
+      try {
+        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_REVERT);
+      }
+      catch (err) {
+        logger.error(err);
+      }
+    });
+
     // likes
     // likes
     this.pageEvent.on('like', async(page, user) => {
     this.pageEvent.on('like', async(page, user) => {
       try {
       try {
@@ -977,6 +986,7 @@ class PageService {
         newPagePath, page.revision.body, user, options,
         newPagePath, page.revision.body, user, options,
       );
       );
     }
     }
+    this.pageEvent.emit('duplicate', page, user);
 
 
     // 4. Take over tags
     // 4. Take over tags
     const originTags = await page.findRelatedTagsById();
     const originTags = await page.findRelatedTagsById();
@@ -1071,6 +1081,7 @@ class PageService {
     const createdPage = await Page.create(
     const createdPage = await Page.create(
       newPagePath, page.revision.body, user, options,
       newPagePath, page.revision.body, user, options,
     );
     );
+    this.pageEvent.emit('duplicate', page, user);
 
 
     if (isRecursively) {
     if (isRecursively) {
       this.duplicateDescendantsWithStream(page, newPagePath, user);
       this.duplicateDescendantsWithStream(page, newPagePath, user);

+ 5 - 0
packages/app/src/server/util/activityDefine.ts

@@ -1,10 +1,13 @@
+// TargetModel
 const MODEL_PAGE = 'Page';
 const MODEL_PAGE = 'Page';
 const MODEL_COMMENT = 'Comment';
 const MODEL_COMMENT = 'Comment';
 
 
+// Activity
 const ACTION_PAGE_LIKE = 'PAGE_LIKE';
 const ACTION_PAGE_LIKE = 'PAGE_LIKE';
 const ACTION_PAGE_BOOKMARK = 'PAGE_BOOKMARK';
 const ACTION_PAGE_BOOKMARK = 'PAGE_BOOKMARK';
 const ACTION_PAGE_UPDATE = 'PAGE_UPDATE';
 const ACTION_PAGE_UPDATE = 'PAGE_UPDATE';
 const ACTION_PAGE_RENAME = 'PAGE_RENAME';
 const ACTION_PAGE_RENAME = 'PAGE_RENAME';
+const ACTION_PAGE_DUPLICATE = 'PAGE_DUPLICATE';
 const ACTION_PAGE_DELETE = 'PAGE_DELETE';
 const ACTION_PAGE_DELETE = 'PAGE_DELETE';
 const ACTION_PAGE_DELETE_COMPLETELY = 'PAGE_DELETE_COMPLETELY';
 const ACTION_PAGE_DELETE_COMPLETELY = 'PAGE_DELETE_COMPLETELY';
 const ACTION_PAGE_REVERT = 'PAGE_REVERT';
 const ACTION_PAGE_REVERT = 'PAGE_REVERT';
@@ -25,6 +28,7 @@ const getSupportActionNames = () => {
     ACTION_PAGE_BOOKMARK,
     ACTION_PAGE_BOOKMARK,
     ACTION_PAGE_UPDATE,
     ACTION_PAGE_UPDATE,
     ACTION_PAGE_RENAME,
     ACTION_PAGE_RENAME,
+    ACTION_PAGE_DUPLICATE,
     ACTION_PAGE_DELETE,
     ACTION_PAGE_DELETE,
     ACTION_PAGE_DELETE_COMPLETELY,
     ACTION_PAGE_DELETE_COMPLETELY,
     ACTION_PAGE_REVERT,
     ACTION_PAGE_REVERT,
@@ -41,6 +45,7 @@ const activityDefine = {
   ACTION_PAGE_BOOKMARK,
   ACTION_PAGE_BOOKMARK,
   ACTION_PAGE_UPDATE,
   ACTION_PAGE_UPDATE,
   ACTION_PAGE_RENAME,
   ACTION_PAGE_RENAME,
+  ACTION_PAGE_DUPLICATE,
   ACTION_PAGE_DELETE,
   ACTION_PAGE_DELETE,
   ACTION_PAGE_DELETE_COMPLETELY,
   ACTION_PAGE_DELETE_COMPLETELY,
   ACTION_PAGE_REVERT,
   ACTION_PAGE_REVERT,

+ 1 - 3
packages/app/src/stores/tag.tsx

@@ -1,11 +1,9 @@
 import { SWRResponse } from 'swr';
 import { SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
 
 
+import { apiGet } from '~/client/util/apiv1-client';
 import { ITagsListApiv1Result } from '~/interfaces/tag';
 import { ITagsListApiv1Result } from '~/interfaces/tag';
 
 
-import { apiGet } from '../client/util/apiv1-client';
-
-
 export const useSWRxTagsList = (limit?: number, offset?: number): SWRResponse<ITagsListApiv1Result, Error> => {
 export const useSWRxTagsList = (limit?: number, offset?: number): SWRResponse<ITagsListApiv1Result, Error> => {
   return useSWRImmutable(
   return useSWRImmutable(
     ['/tags.list', limit, offset],
     ['/tags.list', limit, offset],