PageRevisionTable.tsx 7.7 KB

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