PageRevisionTable.tsx 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. import React, { type JSX, useEffect, useRef, useState } from 'react';
  2. import type { IRevisionHasId } from '@growi/core';
  3. import { useTranslation } from 'next-i18next';
  4. import { useSWRxInfinitePageRevisions } from '~/stores/page';
  5. import { RevisionComparer } from '../RevisionComparer/RevisionComparer';
  6. import { Revision } from './Revision';
  7. import styles from './PageRevisionTable.module.scss';
  8. type PageRevisionTableProps = {
  9. sourceRevisionId?: string;
  10. targetRevisionId?: string;
  11. onClose: () => void;
  12. currentPageId: string;
  13. currentPagePath: string;
  14. };
  15. export const PageRevisionTable = (
  16. props: PageRevisionTableProps,
  17. ): JSX.Element => {
  18. const { t } = useTranslation();
  19. const REVISIONS_PER_PAGE = 10;
  20. const {
  21. sourceRevisionId,
  22. targetRevisionId,
  23. onClose,
  24. currentPageId,
  25. currentPagePath,
  26. } = props;
  27. // Load all data if source revision id and target revision id not null
  28. const revisionPerPage =
  29. sourceRevisionId != null && targetRevisionId != null
  30. ? 0
  31. : REVISIONS_PER_PAGE;
  32. const swrInifiniteResponse = useSWRxInfinitePageRevisions(
  33. currentPageId,
  34. revisionPerPage,
  35. );
  36. const { data, size, error, setSize, isValidating } = swrInifiniteResponse;
  37. const revisions = data && data[0].revisions;
  38. const oldestRevision = revisions && revisions[revisions.length - 1];
  39. // First load
  40. const isLoadingInitialData = !data && !error;
  41. const isLoadingMore =
  42. isLoadingInitialData ||
  43. (isValidating && data != null && typeof data[size - 1] === 'undefined');
  44. const isReachingEnd =
  45. revisionPerPage === 0 ||
  46. !!(
  47. data != null &&
  48. data[data.length - 1]?.revisions.length < REVISIONS_PER_PAGE
  49. );
  50. const [sourceRevision, setSourceRevision] = useState<IRevisionHasId>();
  51. const [targetRevision, setTargetRevision] = useState<IRevisionHasId>();
  52. const tbodyRef = useRef<HTMLTableSectionElement>(null);
  53. useEffect(() => {
  54. if (revisions != null) {
  55. // when both source and target are specified
  56. if (sourceRevisionId != null && targetRevisionId != null) {
  57. const sourceRevision = revisions.filter(
  58. (revision) => revision._id === sourceRevisionId,
  59. )[0];
  60. const targetRevision = revisions.filter(
  61. (revision) => revision._id === targetRevisionId,
  62. )[0];
  63. setSourceRevision(sourceRevision);
  64. setTargetRevision(targetRevision);
  65. } else {
  66. const latestRevision = revisions != null ? revisions[0] : undefined;
  67. const previousRevision =
  68. revisions.length >= 2 ? revisions[1] : latestRevision;
  69. setTargetRevision(latestRevision);
  70. setSourceRevision(previousRevision);
  71. }
  72. }
  73. }, [revisions, sourceRevisionId, targetRevisionId]);
  74. useEffect(() => {
  75. // Apply ref to tbody
  76. const tbody = tbodyRef.current;
  77. const handleScroll = () => {
  78. const offset = 30; // Threshold before scroll actually reaching the end
  79. if (tbody) {
  80. // Scroll end
  81. const isEnd =
  82. tbody.scrollTop + tbody.clientHeight + offset >= tbody.scrollHeight;
  83. if (isEnd && !isLoadingMore && !isReachingEnd) {
  84. setSize(size + 1);
  85. }
  86. }
  87. };
  88. if (tbody) {
  89. tbody.addEventListener('scroll', handleScroll);
  90. }
  91. return () => {
  92. if (tbody) {
  93. tbody.removeEventListener('scroll', handleScroll);
  94. }
  95. };
  96. }, [isLoadingMore, isReachingEnd, setSize, size]);
  97. const renderRow = (
  98. revision: IRevisionHasId,
  99. previousRevision: IRevisionHasId,
  100. latestRevision: IRevisionHasId,
  101. isOldestRevision: boolean,
  102. hasDiff: boolean,
  103. ) => {
  104. const revisionId = revision._id;
  105. const handleCompareLatestRevisionButton = () => {
  106. setSourceRevision(revision);
  107. setTargetRevision(latestRevision);
  108. };
  109. const handleComparePreviousRevisionButton = () => {
  110. setSourceRevision(previousRevision);
  111. setTargetRevision(revision);
  112. };
  113. return (
  114. <tr className="d-flex" key={`revision-history-${revisionId}`}>
  115. <td className="col" key={`revision-history-top-${revisionId}`}>
  116. <div className="d-lg-flex">
  117. <Revision
  118. revision={revision}
  119. isLatestRevision={revision === latestRevision}
  120. hasDiff={hasDiff}
  121. currentPageId={currentPageId}
  122. currentPagePath={currentPagePath}
  123. key={`revision-history-rev-${revisionId}`}
  124. onClose={onClose}
  125. />
  126. {hasDiff && (
  127. <div className="ms-md-3 mt-auto">
  128. <div className="btn-group">
  129. <button
  130. type="button"
  131. className="btn btn-outline-secondary btn-sm"
  132. onClick={handleCompareLatestRevisionButton}
  133. >
  134. {t('page_history.compare_latest')}
  135. </button>
  136. <button
  137. type="button"
  138. className="btn btn-outline-secondary btn-sm"
  139. onClick={handleComparePreviousRevisionButton}
  140. disabled={isOldestRevision}
  141. >
  142. {t('page_history.compare_previous')}
  143. </button>
  144. </div>
  145. </div>
  146. )}
  147. </div>
  148. </td>
  149. <td className="col-1">
  150. {(hasDiff || revisionId === sourceRevision?._id) && (
  151. <div className="form-check form-check-inline me-0">
  152. <input
  153. type="radio"
  154. className="form-check-input"
  155. id={`compareSource-${revisionId}`}
  156. name="compareSource"
  157. value={revisionId}
  158. checked={revisionId === sourceRevision?._id}
  159. onChange={() => setSourceRevision(revision)}
  160. />
  161. </div>
  162. )}
  163. </td>
  164. <td className="col-2">
  165. {(hasDiff || revisionId === targetRevision?._id) && (
  166. <div className="form-check form-check-inline me-0">
  167. <input
  168. type="radio"
  169. className="form-check-input"
  170. id={`compareTarget-${revisionId}`}
  171. name="compareTarget"
  172. value={revisionId}
  173. checked={revisionId === targetRevision?._id}
  174. onChange={() => setTargetRevision(revision)}
  175. />
  176. </div>
  177. )}
  178. </td>
  179. </tr>
  180. );
  181. };
  182. return (
  183. <>
  184. <table
  185. className={`${styles['revision-history-table']} table revision-history-table`}
  186. >
  187. <thead>
  188. <tr className="d-flex">
  189. <th className="col">{t('page_history.revision')}</th>
  190. <th className="col-1">{t('page_history.comparing_source')}</th>
  191. <th className="col-2">{t('page_history.comparing_target')}</th>
  192. </tr>
  193. </thead>
  194. <tbody className="overflow-auto d-block" ref={tbodyRef}>
  195. {revisions != null &&
  196. data != null &&
  197. data
  198. .flatMap((apiResult) => apiResult.revisions)
  199. .map((revision, idx) => {
  200. const previousRevision =
  201. idx + 1 < revisions?.length ? revisions[idx + 1] : revision;
  202. const isOldestRevision = revision === oldestRevision;
  203. const latestRevision = revisions[0];
  204. // set 'true' if undefined for backward compatibility
  205. const hasDiff = revision.hasDiffToPrev !== false;
  206. return renderRow(
  207. revision,
  208. previousRevision,
  209. latestRevision,
  210. isOldestRevision,
  211. hasDiff,
  212. );
  213. })}
  214. </tbody>
  215. </table>
  216. {sourceRevision != null && targetRevision != null && (
  217. <div className="mt-5">
  218. <RevisionComparer
  219. sourceRevision={sourceRevision}
  220. targetRevision={targetRevision}
  221. currentPageId={currentPageId}
  222. currentPagePath={currentPagePath}
  223. onClose={onClose}
  224. />
  225. </div>
  226. )}
  227. </>
  228. );
  229. };