PageRevisionTable.tsx 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. import React, {
  2. useEffect, useRef, useState,
  3. } from 'react';
  4. import type { IRevisionHasId, 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 onChangeSourceInvoked: React.Dispatch<React.SetStateAction<IRevisionHasId | undefined>> = (revision: IRevisionHasPageId) => {
  79. setSourceRevision(revision);
  80. };
  81. const onChangeTargetInvoked: React.Dispatch<React.SetStateAction<IRevisionHasId | undefined>> = (revision: IRevisionHasPageId) => {
  82. setTargetRevision(revision);
  83. };
  84. const renderRow = (revision: IRevisionHasPageId, previousRevision: IRevisionHasPageId, latestRevision: IRevisionHasPageId,
  85. isOldestRevision: boolean, hasDiff: boolean) => {
  86. const revisionId = revision._id;
  87. const handleCompareLatestRevisionButton = () => {
  88. onChangeSourceInvoked(revision);
  89. onChangeTargetInvoked(latestRevision);
  90. };
  91. const handleComparePreviousRevisionButton = () => {
  92. onChangeSourceInvoked(previousRevision);
  93. onChangeTargetInvoked(revision);
  94. };
  95. return (
  96. <tr className="d-flex" key={`revision-history-${revisionId}`}>
  97. <td className="col" key={`revision-history-top-${revisionId}`}>
  98. <div className="d-lg-flex">
  99. <Revision
  100. revision={revision}
  101. isLatestRevision={revision === latestRevision}
  102. hasDiff={hasDiff}
  103. currentPageId={currentPageId}
  104. currentPagePath={currentPagePath}
  105. key={`revision-history-rev-${revisionId}`}
  106. onClose={onClose}
  107. />
  108. {hasDiff && (
  109. <div className="ml-md-3 mt-auto">
  110. <div className="btn-group">
  111. <button
  112. type="button"
  113. className="btn btn-outline-secondary btn-sm"
  114. onClick={handleCompareLatestRevisionButton}
  115. >
  116. {t('page_history.compare_latest')}
  117. </button>
  118. <button
  119. type="button"
  120. className="btn btn-outline-secondary btn-sm"
  121. onClick={handleComparePreviousRevisionButton}
  122. disabled={isOldestRevision}
  123. >
  124. {t('page_history.compare_previous')}
  125. </button>
  126. </div>
  127. </div>
  128. )}
  129. </div>
  130. </td>
  131. <td className="col-1">
  132. {(hasDiff || revisionId === sourceRevision?._id) && (
  133. <div className="form-check form-check-inline mr-0">
  134. <input
  135. type="radio"
  136. className="form-check-input"
  137. id={`compareSource-${revisionId}`}
  138. name="compareSource"
  139. value={revisionId}
  140. checked={revisionId === sourceRevision?._id}
  141. onChange={() => onChangeSourceInvoked(revision)}
  142. />
  143. <label className="form-check-label" htmlFor={`compareSource-${revisionId}`} />
  144. </div>
  145. )}
  146. </td>
  147. <td className="col-2">
  148. {(hasDiff || revisionId === targetRevision?._id) && (
  149. <div className="form-check form-check-inline mr-0">
  150. <input
  151. type="radio"
  152. className="form-check-input"
  153. id={`compareTarget-${revisionId}`}
  154. name="compareTarget"
  155. value={revisionId}
  156. checked={revisionId === targetRevision?._id}
  157. onChange={() => onChangeTargetInvoked(revision)}
  158. />
  159. <label className="form-check-label" htmlFor={`compareTarget-${revisionId}`} />
  160. </div>
  161. )}
  162. </td>
  163. </tr>
  164. );
  165. };
  166. return (
  167. <>
  168. <table className={`${styles['revision-history-table']} table revision-history-table`}>
  169. <thead>
  170. <tr className="d-flex">
  171. <th className="col">{t('page_history.revision')}</th>
  172. <th className="col-1">{t('page_history.comparing_source')}</th>
  173. <th className="col-2">{t('page_history.comparing_target')}</th>
  174. </tr>
  175. </thead>
  176. <tbody className="overflow-auto d-block" ref={tbodyRef}>
  177. {revisions != null && data != null && data.map(apiResult => apiResult.revisions).flat()
  178. .map((revision, idx) => {
  179. const previousRevision = (idx + 1 < revisions?.length) ? revisions[idx + 1] : revision;
  180. const isOldestRevision = revision === oldestRevision;
  181. const latestRevision = revisions[0];
  182. // set 'true' if undefined for backward compatibility
  183. const hasDiff = revision.hasDiffToPrev !== false;
  184. return renderRow(revision, previousRevision, latestRevision, isOldestRevision, hasDiff);
  185. })
  186. }
  187. </tbody>
  188. </table>
  189. {sourceRevision != null && targetRevision != null && (
  190. <RevisionComparer
  191. sourceRevision={sourceRevision}
  192. targetRevision={targetRevision}
  193. currentPageId={currentPageId}
  194. currentPagePath={currentPagePath}
  195. onClose={onClose}
  196. />
  197. )
  198. }
  199. </>
  200. );
  201. };