PageListItemL.tsx 13 KB

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