PageListItemL.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. import React, {
  2. forwardRef, useState,
  3. ForwardRefRenderFunction, memo, useCallback, useImperativeHandle, useRef, useEffect,
  4. } from 'react';
  5. import { DevidedPagePath, pathUtils } from '@growi/core';
  6. import { PageListMeta } from '@growi/ui/dist/components/PagePath/PageListMeta';
  7. import { UserPicture } from '@growi/ui/dist/components/User/UserPicture';
  8. import { format } from 'date-fns';
  9. import { useTranslation } from 'next-i18next';
  10. import Link from 'next/link';
  11. import Clamp from 'react-multiline-clamp';
  12. import { CustomInput } from 'reactstrap';
  13. import { ISelectable } from '~/client/interfaces/selectable-all';
  14. import { unlink, bookmark, unbookmark } from '~/client/services/page-operation';
  15. import { toastError } from '~/client/util/toastr';
  16. import {
  17. IPageInfoAll, isIPageInfoForListing, isIPageInfoForEntity, IPageWithMeta, IPageInfoForListing,
  18. } from '~/interfaces/page';
  19. import { IPageSearchMeta, IPageWithSearchMeta, isIPageSearchMeta } from '~/interfaces/search';
  20. import {
  21. OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction,
  22. } from '~/interfaces/ui';
  23. import LinkedPagePath from '~/models/linked-page-path';
  24. import { useSWRBookmarkInfo, useSWRxUserBookmarks } from '~/stores/bookmark';
  25. import {
  26. usePageRenameModal, usePageDuplicateModal, usePageDeleteModal, usePutBackPageModal,
  27. } from '~/stores/modal';
  28. import { useIsDeviceSmallerThanLg } from '~/stores/ui';
  29. import { useSWRxPageInfo } from '../../stores/page';
  30. import { ForceHideMenuItems, PageItemControl } from '../Common/Dropdown/PageItemControl';
  31. import PagePathHierarchicalLink from '../PagePathHierarchicalLink';
  32. type Props = {
  33. page: IPageWithSearchMeta | IPageWithMeta<IPageInfoForListing & IPageSearchMeta>,
  34. isSelected?: boolean, // is item selected(focused)
  35. isEnableActions?: boolean,
  36. isReadOnlyUser: boolean,
  37. forceHideMenuItems?: ForceHideMenuItems,
  38. showPageUpdatedTime?: boolean, // whether to show page's updated time at the top-right corner of item
  39. onCheckboxChanged?: (isChecked: boolean, pageId: string) => void,
  40. onClickItem?: (pageId: string) => void,
  41. onPageDuplicated?: OnDuplicatedFunction,
  42. onPageRenamed?: OnRenamedFunction,
  43. onPageDeleted?: OnDeletedFunction,
  44. onPagePutBacked?: OnPutBackedFunction,
  45. }
  46. const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (props: Props, ref): JSX.Element => {
  47. const {
  48. page: { data: pageData, meta: pageMeta }, isSelected, isEnableActions, isReadOnlyUser,
  49. forceHideMenuItems,
  50. showPageUpdatedTime,
  51. onClickItem, onCheckboxChanged, onPageDuplicated, onPageRenamed, onPageDeleted, onPagePutBacked,
  52. } = props;
  53. const { returnPathForURL } = pathUtils;
  54. const [likerCount, setLikerCount] = useState(pageData.liker.length);
  55. const [bookmarkCount, setBookmarkCount] = useState(pageMeta && pageMeta.bookmarkCount ? pageMeta.bookmarkCount : 0);
  56. const { t } = useTranslation();
  57. const inputRef = useRef<HTMLInputElement>(null);
  58. // publish ISelectable methods
  59. useImperativeHandle(ref, () => ({
  60. select: () => {
  61. const input = inputRef.current;
  62. if (input != null) {
  63. input.checked = true;
  64. }
  65. },
  66. deselect: () => {
  67. const input = inputRef.current;
  68. if (input != null) {
  69. input.checked = false;
  70. }
  71. },
  72. }));
  73. const { data: isDeviceSmallerThanLg } = useIsDeviceSmallerThanLg();
  74. const { open: openDuplicateModal } = usePageDuplicateModal();
  75. const { open: openRenameModal } = usePageRenameModal();
  76. const { open: openDeleteModal } = usePageDeleteModal();
  77. const { open: openPutBackPageModal } = usePutBackPageModal();
  78. const shouldFetch = isSelected && (pageData != null || pageMeta != null);
  79. const { data: pageInfo } = useSWRxPageInfo(shouldFetch ? pageData?._id : null);
  80. const { mutate: mutateUserBookmark } = useSWRxUserBookmarks();
  81. const { mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(pageData?._id);
  82. const elasticSearchResult = isIPageSearchMeta(pageMeta) ? pageMeta.elasticSearchResult : null;
  83. const revisionShortBody = isIPageInfoForListing(pageMeta) ? pageMeta.revisionShortBody : null;
  84. const dPagePath: DevidedPagePath = new DevidedPagePath(pageData.path, false);
  85. const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
  86. const dPagePathHighlighted: DevidedPagePath = new DevidedPagePath(elasticSearchResult?.highlightedPath || pageData.path, true);
  87. const linkedPagePathHighlightedFormer = new LinkedPagePath(dPagePathHighlighted.former);
  88. const linkedPagePathHighlightedLatter = new LinkedPagePath(dPagePathHighlighted.latter);
  89. const lastUpdateDate = format(new Date(pageData.updatedAt), 'yyyy/MM/dd HH:mm:ss');
  90. useEffect(() => {
  91. if (isIPageInfoForEntity(pageInfo)) {
  92. // likerCount
  93. setLikerCount(pageInfo.likerIds?.length ?? 0);
  94. // bookmarkCount
  95. setBookmarkCount(pageInfo.bookmarkCount ?? 0);
  96. }
  97. }, [pageInfo]);
  98. // click event handler
  99. const clickHandler = useCallback(() => {
  100. // do nothing if mobile
  101. if (isDeviceSmallerThanLg) {
  102. return;
  103. }
  104. if (onClickItem != null) {
  105. onClickItem(pageData._id);
  106. }
  107. }, [isDeviceSmallerThanLg, onClickItem, pageData._id]);
  108. const bookmarkMenuItemClickHandler = async(_pageId: string, _newValue: boolean): Promise<void> => {
  109. const bookmarkOperation = _newValue ? bookmark : unbookmark;
  110. await bookmarkOperation(_pageId);
  111. mutateUserBookmark();
  112. mutateBookmarkInfo();
  113. };
  114. const duplicateMenuItemClickHandler = useCallback(() => {
  115. const page = {
  116. pageId: pageData._id,
  117. path: pageData.path,
  118. };
  119. openDuplicateModal(page, { onDuplicated: onPageDuplicated });
  120. }, [onPageDuplicated, openDuplicateModal, pageData._id, pageData.path]);
  121. const renameMenuItemClickHandler = useCallback((_id: string, pageInfo: IPageInfoAll | undefined) => {
  122. const page = { data: pageData, meta: pageInfo };
  123. openRenameModal(page, { onRenamed: onPageRenamed });
  124. }, [pageData, onPageRenamed, openRenameModal]);
  125. const deleteMenuItemClickHandler = useCallback((_id: string, pageInfo: IPageInfoAll | undefined) => {
  126. const pageToDelete = { data: pageData, meta: pageInfo };
  127. // open modal
  128. openDeleteModal([pageToDelete], { onDeleted: onPageDeleted });
  129. }, [pageData, openDeleteModal, onPageDeleted]);
  130. const revertMenuItemClickHandler = useCallback(async() => {
  131. const { _id: pageId, path } = pageData;
  132. const putBackedHandler = async(path) => {
  133. try {
  134. // pageData path should be `/trash/fuga` (`/trash` should be included to the prefix)
  135. await unlink(pageData.path);
  136. }
  137. catch (err) {
  138. toastError(err);
  139. }
  140. if (onPagePutBacked != null) {
  141. // This path should be `/fuga` ( `/trash` is not included to the prefix)
  142. onPagePutBacked(path);
  143. }
  144. };
  145. openPutBackPageModal({ pageId, path }, { onPutBacked: putBackedHandler });
  146. }, [onPagePutBacked, openPutBackPageModal, pageData]);
  147. const styleListGroupItem = (!isDeviceSmallerThanLg && onClickItem != null) ? 'list-group-item-action' : '';
  148. // background color of list item changes when class "active" exists under 'list-group-item'
  149. const styleActive = !isDeviceSmallerThanLg && isSelected ? 'active' : '';
  150. const shouldDangerouslySetInnerHTMLForPaths = elasticSearchResult != null && elasticSearchResult.highlightedPath != null;
  151. const canRenderESSnippet = elasticSearchResult != null && elasticSearchResult.snippet != null;
  152. const canRenderRevisionSnippet = revisionShortBody != null;
  153. const hasBrowsingRights = canRenderESSnippet || canRenderRevisionSnippet;
  154. return (
  155. <li
  156. key={pageData._id}
  157. className={`list-group-item d-flex align-items-center px-3 px-md-1 ${styleListGroupItem} ${styleActive}`}
  158. data-testid="page-list-item-L"
  159. onClick={clickHandler}
  160. >
  161. <div className="text-break w-100">
  162. <div className="d-flex">
  163. {/* checkbox */}
  164. {onCheckboxChanged != null && (
  165. <div className="d-flex align-items-center justify-content-center">
  166. <CustomInput
  167. type="checkbox"
  168. id={`cbSelect-${pageData._id}`}
  169. data-testid="cb-select"
  170. innerRef={inputRef}
  171. onChange={(e) => { onCheckboxChanged(e.target.checked, pageData._id) }}
  172. />
  173. </div>
  174. )}
  175. <div className="flex-grow-1 px-2 px-md-4">
  176. <div className="d-flex justify-content-between">
  177. {/* page path */}
  178. <PagePathHierarchicalLink
  179. linkedPagePath={linkedPagePathFormer}
  180. linkedPagePathByHtml={linkedPagePathHighlightedFormer}
  181. />
  182. { showPageUpdatedTime && (
  183. <span className="page-list-updated-at text-muted">Last update: {lastUpdateDate}</span>
  184. ) }
  185. </div>
  186. <div className="d-flex align-items-center mb-1">
  187. {/* Picture */}
  188. <span className="mr-2 d-none d-md-block">
  189. <UserPicture user={pageData.lastUpdateUser} size="md" />
  190. </span>
  191. {/* page title */}
  192. <Clamp lines={1}>
  193. <span className="h5 mb-0">
  194. {/* Use permanent links to care for pages with the same name (Cannot use page path url) */}
  195. <span className="grw-page-path-hierarchical-link text-break">
  196. <Link legacyBehavior
  197. href={returnPathForURL(pageData.path, pageData._id)}
  198. prefetch={false}
  199. >
  200. {shouldDangerouslySetInnerHTMLForPaths
  201. ? (
  202. <a
  203. className="page-segment"
  204. // eslint-disable-next-line react/no-danger
  205. dangerouslySetInnerHTML={{ __html: linkedPagePathHighlightedLatter.pathName }}
  206. >
  207. </a>
  208. )
  209. : <a className="page-segment">{linkedPagePathHighlightedLatter.pathName}</a>
  210. }
  211. </Link>
  212. </span>
  213. </span>
  214. </Clamp>
  215. {/* page meta */}
  216. <div className="d-none d-md-flex py-0 px-1 ml-2 text-nowrap">
  217. <PageListMeta page={pageData} likerCount={likerCount} bookmarkCount={bookmarkCount} shouldSpaceOutIcon />
  218. </div>
  219. {/* doropdown icon includes page control buttons */}
  220. {hasBrowsingRights
  221. && <div className="ml-auto">
  222. <PageItemControl
  223. alignRight
  224. pageId={pageData._id}
  225. pageInfo={isIPageInfoForListing(pageMeta) ? pageMeta : undefined}
  226. isEnableActions={isEnableActions}
  227. isReadOnlyUser={isReadOnlyUser}
  228. forceHideMenuItems={forceHideMenuItems}
  229. onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
  230. onClickRenameMenuItem={renameMenuItemClickHandler}
  231. onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
  232. onClickDeleteMenuItem={deleteMenuItemClickHandler}
  233. onClickRevertMenuItem={revertMenuItemClickHandler}
  234. />
  235. </div>
  236. }
  237. </div>
  238. <div className="page-list-snippet py-1">
  239. <Clamp lines={2}>
  240. { elasticSearchResult != null && elasticSearchResult.snippet != null && (
  241. // eslint-disable-next-line react/no-danger
  242. <div dangerouslySetInnerHTML={{ __html: elasticSearchResult.snippet }}></div>
  243. ) }
  244. { revisionShortBody != null && (
  245. <div data-testid="revision-short-body-in-page-list-item-L">{revisionShortBody}</div>
  246. ) }
  247. {
  248. !hasBrowsingRights && (
  249. <>
  250. <i className="icon-exclamation p-1"></i>
  251. {t('not_allowed_to_see_this_page')}
  252. </>
  253. )
  254. }
  255. </Clamp>
  256. </div>
  257. </div>
  258. </div>
  259. {/* TODO: adjust snippet position */}
  260. </div>
  261. </li>
  262. );
  263. };
  264. export const PageListItemL = memo(forwardRef(PageListItemLSubstance));