Add infinite scrolling to history versions list (#12752)

* Add infinite scrolling to change list, add spinner while doing initial updates load, refactor updates state

* Update type in tests

* Update types

* Using LoadingSpinner component

* Remove redundant imports

GitOrigin-RevId: 98c7eae8edbc4d10d7107d825045edfc4159494f
This commit is contained in:
Tim Down 2023-04-24 14:55:07 +01:00 committed by Copybot
parent bf076a8c97
commit afd1195902
11 changed files with 223 additions and 110 deletions

View file

@ -1,21 +1,79 @@
import { Fragment } from 'react' import { Fragment, useEffect, useRef, useState } from 'react'
import { useHistoryContext } from '../../context/history-context' import { useHistoryContext } from '../../context/history-context'
import HistoryVersion from './history-version' import HistoryVersion from './history-version'
import LoadingSpinner from '../../../../shared/components/loading-spinner'
function AllHistoryList() { function AllHistoryList() {
const { updates } = useHistoryContext() const { updatesInfo, loadingState, fetchNextBatchOfUpdates } =
useHistoryContext()
const { updates, atEnd } = updatesInfo
const scrollerRef = useRef<HTMLDivElement>(null)
const bottomRef = useRef<HTMLDivElement>(null)
const intersectionObserverRef = useRef<IntersectionObserver | null>(null)
const [bottomVisible, setBottomVisible] = useState(false)
// Create an intersection observer that watches for any part of an element
// positioned at the bottom of the list to be visible
useEffect(() => {
if (loadingState === 'ready' && !intersectionObserverRef.current) {
const scroller = scrollerRef.current
const bottom = bottomRef.current
if (scroller && bottom) {
intersectionObserverRef.current = new IntersectionObserver(
entries => {
setBottomVisible(entries[0].isIntersecting)
},
{ root: scroller }
)
intersectionObserverRef.current.observe(bottom)
return () => {
if (intersectionObserverRef.current) {
intersectionObserverRef.current.disconnect()
}
}
}
}
}, [loadingState])
useEffect(() => {
if (!atEnd && loadingState === 'ready' && bottomVisible) {
fetchNextBatchOfUpdates()
}
}, [atEnd, bottomVisible, fetchNextBatchOfUpdates, loadingState])
// While updates are loading, remove the intersection observer and set
// bottomVisible to false. This is to avoid loading more updates immediately
// after rendering the pending updates, which would happen otherwise, because
// the intersection observer is asynchronous and won't have noticed that the
// bottom is no longer visible
useEffect(() => {
if (loadingState !== 'ready' && intersectionObserverRef.current) {
setBottomVisible(false)
if (intersectionObserverRef.current) {
intersectionObserverRef.current.disconnect()
intersectionObserverRef.current = null
}
}
}, [loadingState])
return ( return (
<> <div ref={scrollerRef} className="history-all-versions-scroller">
{updates.map((update, index) => ( <div className="history-all-versions-container">
<Fragment key={`${update.fromV}_${update.toV}`}> <div ref={bottomRef} className="history-versions-bottom" />
{update.meta.first_in_day && index > 0 && ( {updates.map((update, index) => (
<hr className="history-version-divider" /> <Fragment key={`${update.fromV}_${update.toV}`}>
)} {update.meta.first_in_day && index > 0 && (
<HistoryVersion update={update} /> <hr className="history-version-divider" />
</Fragment> )}
))} <HistoryVersion update={update} />
</> </Fragment>
))}
</div>
{loadingState === 'ready' ? null : <LoadingSpinner />}
</div>
) )
} }

View file

@ -20,7 +20,7 @@ type TagProps = {
function Tag({ label, currentUserId, ...props }: TagProps) { function Tag({ label, currentUserId, ...props }: TagProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [showDeleteModal, setShowDeleteModal] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false)
const { projectId, updates, setUpdates, labels, setLabels } = const { projectId, updatesInfo, setUpdatesInfo, labels, setLabels } =
useHistoryContext() useHistoryContext()
const { isLoading, isSuccess, isError, error, runAsync } = useAsync() const { isLoading, isSuccess, isError, error, runAsync } = useAsync()
const isPseudoCurrentStateLabel = isPseudoLabel(label) const isPseudoCurrentStateLabel = isPseudoLabel(label)
@ -36,7 +36,7 @@ function Tag({ label, currentUserId, ...props }: TagProps) {
const handleModalExited = () => { const handleModalExited = () => {
if (!isSuccess) return if (!isSuccess) return
const tempUpdates = [...updates] const tempUpdates = [...updatesInfo.updates]
for (const [i, update] of tempUpdates.entries()) { for (const [i, update] of tempUpdates.entries()) {
if (update.toV === label.version) { if (update.toV === label.version) {
tempUpdates[i] = { tempUpdates[i] = {
@ -47,7 +47,7 @@ function Tag({ label, currentUserId, ...props }: TagProps) {
} }
} }
setUpdates(tempUpdates) setUpdatesInfo({ ...updatesInfo, updates: tempUpdates })
if (labels) { if (labels) {
const nonPseudoLabels = labels.filter(isLabel) const nonPseudoLabels = labels.filter(isLabel)

View file

@ -1,7 +1,7 @@
import { Nullable } from '../../../../../../types/utils' import { Nullable } from '../../../../../../types/utils'
import { Diff } from '../../services/types/doc' import { Diff } from '../../services/types/doc'
import DocumentDiffViewer from './document-diff-viewer' import DocumentDiffViewer from './document-diff-viewer'
import { useTranslation } from 'react-i18next' import LoadingSpinner from '../../../../shared/components/loading-spinner'
type MainProps = { type MainProps = {
diff: Nullable<Diff> diff: Nullable<Diff>
@ -9,16 +9,8 @@ type MainProps = {
} }
function Main({ diff, isLoading }: MainProps) { function Main({ diff, isLoading }: MainProps) {
const { t } = useTranslation()
if (isLoading) { if (isLoading) {
return ( return <LoadingSpinner />
<div className="history-loading-panel">
<i className="fa fa-spin fa-refresh" />
&nbsp;&nbsp;
{t('loading')}
</div>
)
} }
if (!diff) { if (!diff) {

View file

@ -3,15 +3,12 @@ import DiffView from './diff-view/diff-view'
import { HistoryProvider, useHistoryContext } from '../context/history-context' import { HistoryProvider, useHistoryContext } from '../context/history-context'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import HistoryFileTree from './history-file-tree' import HistoryFileTree from './history-file-tree'
import LoadingSpinner from '../../../shared/components/loading-spinner'
const fileTreeContainer = document.getElementById('history-file-tree') const fileTreeContainer = document.getElementById('history-file-tree')
function Main() { function Main() {
const { updates } = useHistoryContext() const { loadingState } = useHistoryContext()
if (updates.length === 0) {
return null
}
return ( return (
<> <>
@ -19,8 +16,14 @@ function Main() {
? createPortal(<HistoryFileTree />, fileTreeContainer) ? createPortal(<HistoryFileTree />, fileTreeContainer)
: null} : null}
<div className="history-react"> <div className="history-react">
<DiffView /> {loadingState === 'loadingInitial' ? (
<ChangeList /> <LoadingSpinner />
) : (
<>
<DiffView />
<ChangeList />
</>
)}
</div> </div>
</> </>
) )

View file

@ -19,10 +19,44 @@ import ColorManager from '../../../ide/colors/ColorManager'
import moment from 'moment' import moment from 'moment'
import * as eventTracking from '../../../infrastructure/event-tracking' import * as eventTracking from '../../../infrastructure/event-tracking'
import { cloneDeep } from 'lodash' import { cloneDeep } from 'lodash'
import { LoadedUpdate, Update } from '../services/types/update' import {
FetchUpdatesResponse,
LoadedUpdate,
Update,
} from '../services/types/update'
import { Nullable } from '../../../../../types/utils' import { Nullable } from '../../../../../types/utils'
import { Selection } from '../services/types/selection' import { Selection } from '../services/types/selection'
// Allow testing of infinite scrolling by providing query string parameters to
// limit the number of updates returned in a batch and apply a delay
function limitUpdates(
promise: Promise<FetchUpdatesResponse>
): Promise<FetchUpdatesResponse> {
const queryParams = new URLSearchParams(window.location.search)
const maxBatchSizeParam = queryParams.get('history-max-updates')
const delayParam = queryParams.get('history-updates-delay')
if (delayParam === null && maxBatchSizeParam === null) {
return promise
}
return promise.then(response => {
let { updates, nextBeforeTimestamp } = response
const maxBatchSize = maxBatchSizeParam ? parseInt(maxBatchSizeParam, 10) : 0
const delay = delayParam ? parseInt(delayParam, 10) : 0
if (maxBatchSize > 0 && updates) {
updates = updates.slice(0, maxBatchSize)
nextBeforeTimestamp = updates[updates.length - 1].fromV
}
const limitedResponse = { updates, nextBeforeTimestamp }
if (delay > 0) {
return new Promise(resolve => {
window.setTimeout(() => resolve(limitedResponse), delay)
})
} else {
return limitedResponse
}
})
}
function useHistory() { function useHistory() {
const { view } = useLayoutContext() const { view } = useLayoutContext()
const user = useUserContext() const user = useUserContext()
@ -38,17 +72,17 @@ function useHistory() {
pathname: null, pathname: null,
}) })
const [updates, setUpdates] = useState<LoadedUpdate[]>([]) const [updatesInfo, setUpdatesInfo] = useState<
const [loadingFileTree, setLoadingFileTree] = HistoryContextValue['updatesInfo']
useState<HistoryContextValue['loadingFileTree']>(true) >({
// eslint-disable-next-line no-unused-vars updates: [],
const [nextBeforeTimestamp, setNextBeforeTimestamp] = atEnd: false,
useState<HistoryContextValue['nextBeforeTimestamp']>() freeHistoryLimitHit: false,
const [atEnd, setAtEnd] = useState<HistoryContextValue['atEnd']>(false) nextBeforeTimestamp: undefined,
const [freeHistoryLimitHit, setFreeHistoryLimitHit] = })
useState<HistoryContextValue['freeHistoryLimitHit']>(false)
const [labels, setLabels] = useState<HistoryContextValue['labels']>(null) const [labels, setLabels] = useState<HistoryContextValue['labels']>(null)
const [isLoading, setIsLoading] = useState(false) const [loadingState, setLoadingState] =
useState<HistoryContextValue['loadingState']>('loadingInitial')
const [error, setError] = useState(null) const [error, setError] = useState(null)
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
const [userHasFullFeature, setUserHasFullFeature] = const [userHasFullFeature, setUserHasFullFeature] =
@ -58,6 +92,7 @@ function useHistory() {
const loadUpdates = (updatesData: Update[]) => { const loadUpdates = (updatesData: Update[]) => {
const dateTimeNow = new Date() const dateTimeNow = new Date()
const timestamp24hoursAgo = dateTimeNow.setDate(dateTimeNow.getDate() - 1) const timestamp24hoursAgo = dateTimeNow.setDate(dateTimeNow.getDate() - 1)
let { updates, freeHistoryLimitHit } = updatesInfo
let previousUpdate = updates[updates.length - 1] let previousUpdate = updates[updates.length - 1]
let cutOffIndex: Nullable<number> = null let cutOffIndex: Nullable<number> = null
@ -79,7 +114,7 @@ function useHistory() {
if (userHasFullFeature && update.meta.end_ts < timestamp24hoursAgo) { if (userHasFullFeature && update.meta.end_ts < timestamp24hoursAgo) {
cutOffIndex = index || 1 // Make sure that we show at least one entry (to allow labelling). cutOffIndex = index || 1 // Make sure that we show at least one entry (to allow labelling).
setFreeHistoryLimitHit(true) freeHistoryLimitHit = true
if (projectOwnerId === userId) { if (projectOwnerId === userId) {
eventTracking.send( eventTracking.send(
'subscription-funnel', 'subscription-funnel',
@ -98,20 +133,19 @@ function useHistory() {
loadedUpdates = loadedUpdates.slice(0, cutOffIndex) loadedUpdates = loadedUpdates.slice(0, cutOffIndex)
} }
setUpdates(updates.concat(loadedUpdates)) return { updates: updates.concat(loadedUpdates), freeHistoryLimitHit }
// TODO first load
// if (firstLoad) {
// _handleHistoryUIStateChange()
// }
} }
if (atEnd) return if (updatesInfo.atEnd || loadingState === 'loadingUpdates') return
const updatesPromise = fetchUpdates(projectId, nextBeforeTimestamp) const updatesPromise = limitUpdates(
fetchUpdates(projectId, updatesInfo.nextBeforeTimestamp)
)
const labelsPromise = labels == null ? fetchLabels(projectId) : null const labelsPromise = labels == null ? fetchLabels(projectId) : null
setIsLoading(true) setLoadingState(
loadingState === 'ready' ? 'loadingUpdates' : 'loadingInitial'
)
Promise.all([updatesPromise, labelsPromise]) Promise.all([updatesPromise, labelsPromise])
.then(([{ updates: updatesData, nextBeforeTimestamp }, labels]) => { .then(([{ updates: updatesData, nextBeforeTimestamp }, labels]) => {
const lastUpdateToV = updatesData.length ? updatesData[0].toV : null const lastUpdateToV = updatesData.length ? updatesData[0].toV : null
@ -120,37 +154,33 @@ function useHistory() {
setLabels(loadLabels(labels, lastUpdateToV)) setLabels(loadLabels(labels, lastUpdateToV))
} }
loadUpdates(updatesData) const { updates, freeHistoryLimitHit } = loadUpdates(updatesData)
setNextBeforeTimestamp(nextBeforeTimestamp)
if ( const atEnd =
nextBeforeTimestamp == null || nextBeforeTimestamp == null || freeHistoryLimitHit || !updates.length
freeHistoryLimitHit ||
!updates.length setUpdatesInfo({
) { updates,
setAtEnd(true) freeHistoryLimitHit,
} atEnd,
if (!updates.length) { nextBeforeTimestamp,
setLoadingFileTree(false) })
}
}) })
.catch(error => { .catch(error => {
setError(error) setError(error)
setAtEnd(true) setUpdatesInfo({ ...updatesInfo, atEnd: true })
setLoadingFileTree(false)
}) })
.finally(() => { .finally(() => {
setIsLoading(false) setLoadingState('ready')
}) })
}, [ }, [
atEnd, loadingState,
freeHistoryLimitHit,
labels, labels,
nextBeforeTimestamp,
projectId, projectId,
projectOwnerId, projectOwnerId,
userId, userId,
userHasFullFeature, userHasFullFeature,
updates, updatesInfo,
]) ])
// Initial load when the History tab is active // Initial load when the History tab is active
@ -163,6 +193,7 @@ function useHistory() {
}, [view, fetchNextBatchOfUpdates]) }, [view, fetchNextBatchOfUpdates])
const { updateRange, comparing } = selection const { updateRange, comparing } = selection
const { updates } = updatesInfo
// Load files when the update selection changes // Load files when the update selection changes
useEffect(() => { useEffect(() => {
@ -190,7 +221,7 @@ function useHistory() {
}, [updateRange, projectId, updates, comparing]) }, [updateRange, projectId, updates, comparing])
useEffect(() => { useEffect(() => {
// Set update selection if there isn't one // Set update range if there isn't one and updates have loaded
if (updates.length && !updateRange) { if (updates.length && !updateRange) {
setSelection({ setSelection({
updateRange: { updateRange: {
@ -208,36 +239,30 @@ function useHistory() {
const value = useMemo<HistoryContextValue>( const value = useMemo<HistoryContextValue>(
() => ({ () => ({
atEnd,
error, error,
isLoading, loadingState,
freeHistoryLimitHit, updatesInfo,
setUpdatesInfo,
labels, labels,
setLabels, setLabels,
loadingFileTree,
nextBeforeTimestamp,
updates,
setUpdates,
userHasFullFeature, userHasFullFeature,
projectId, projectId,
selection, selection,
setSelection, setSelection,
fetchNextBatchOfUpdates,
}), }),
[ [
atEnd,
error, error,
isLoading, loadingState,
freeHistoryLimitHit, updatesInfo,
setUpdatesInfo,
labels, labels,
setLabels, setLabels,
loadingFileTree,
nextBeforeTimestamp,
updates,
setUpdates,
userHasFullFeature, userHasFullFeature,
projectId, projectId,
selection, selection,
setSelection, setSelection,
fetchNextBatchOfUpdates,
] ]
) )

View file

@ -4,20 +4,22 @@ import { LoadedLabel } from '../../services/types/label'
import { Selection } from '../../services/types/selection' import { Selection } from '../../services/types/selection'
export type HistoryContextValue = { export type HistoryContextValue = {
updates: LoadedUpdate[] updatesInfo: {
setUpdates: React.Dispatch< updates: LoadedUpdate[]
React.SetStateAction<HistoryContextValue['updates']> atEnd: boolean
nextBeforeTimestamp: number | undefined
freeHistoryLimitHit: boolean
}
setUpdatesInfo: React.Dispatch<
React.SetStateAction<HistoryContextValue['updatesInfo']>
> >
nextBeforeTimestamp: number | undefined
atEnd: boolean
userHasFullFeature: boolean | undefined userHasFullFeature: boolean | undefined
freeHistoryLimitHit: boolean loadingState: 'loadingInitial' | 'loadingUpdates' | 'ready'
isLoading: boolean
error: Nullable<unknown> error: Nullable<unknown>
labels: Nullable<LoadedLabel[]> labels: Nullable<LoadedLabel[]>
setLabels: React.Dispatch<React.SetStateAction<HistoryContextValue['labels']>> setLabels: React.Dispatch<React.SetStateAction<HistoryContextValue['labels']>>
loadingFileTree: boolean
projectId: string projectId: string
selection: Selection selection: Selection
setSelection: (selection: Selection) => void setSelection: (selection: Selection) => void
fetchNextBatchOfUpdates: () => void
} }

View file

@ -1,6 +1,6 @@
import { getJSON } from '../../../infrastructure/fetch-json' import { getJSON } from '../../../infrastructure/fetch-json'
import { FileDiff } from './types/file' import { FileDiff } from './types/file'
import { Update } from './types/update' import { FetchUpdatesResponse } from './types/update'
import { Label } from './types/label' import { Label } from './types/label'
import { DocDiffResponse } from './types/doc' import { DocDiffResponse } from './types/doc'
@ -17,10 +17,7 @@ export function fetchUpdates(projectId: string, before?: number) {
const queryParamsSerialized = new URLSearchParams(queryParams).toString() const queryParamsSerialized = new URLSearchParams(queryParams).toString()
const updatesURL = `/project/${projectId}/updates?${queryParamsSerialized}` const updatesURL = `/project/${projectId}/updates?${queryParamsSerialized}`
return getJSON<{ return getJSON<FetchUpdatesResponse>(updatesURL)
updates: Update[]
nextBeforeTimestamp?: number
}>(updatesURL)
} }
export function fetchLabels(projectId: string) { export function fetchLabels(projectId: string) {

View file

@ -41,3 +41,8 @@ interface LoadedUpdateMeta extends Meta {
export interface LoadedUpdate extends Update { export interface LoadedUpdate extends Update {
meta: LoadedUpdateMeta meta: LoadedUpdateMeta
} }
export type FetchUpdatesResponse = {
updates: Update[]
nextBeforeTimestamp?: number
}

View file

@ -1,13 +1,12 @@
import _ from 'lodash' import _ from 'lodash'
import type { Nullable } from '../../../../../types/utils' import type { Nullable } from '../../../../../types/utils'
import type { HistoryContextValue } from '../context/types/history-context-value'
import type { FileDiff } from '../services/types/file' import type { FileDiff } from '../services/types/file'
import type { DiffOperation } from '../services/types/diff-operation' import type { DiffOperation } from '../services/types/diff-operation'
import type { LoadedUpdate, Version } from '../services/types/update' import type { LoadedUpdate, Version } from '../services/types/update'
function getUpdateForVersion( function getUpdateForVersion(
version: Version, version: Version,
updates: HistoryContextValue['updates'] updates: LoadedUpdate[]
): Nullable<LoadedUpdate> { ): Nullable<LoadedUpdate> {
return updates.filter(update => update.toV === version)?.[0] ?? null return updates.filter(update => update.toV === version)?.[0] ?? null
} }
@ -21,7 +20,7 @@ function getFilesWithOps(
files: FileDiff[], files: FileDiff[],
toV: Version, toV: Version,
comparing: boolean, comparing: boolean,
updates: HistoryContextValue['updates'] updates: LoadedUpdate[]
): FileWithOps[] { ): FileWithOps[] {
if (toV && !comparing) { if (toV && !comparing) {
const filesWithOps: FileWithOps[] = [] const filesWithOps: FileWithOps[] = []
@ -92,7 +91,7 @@ export function autoSelectFile(
files: FileDiff[], files: FileDiff[],
toV: Version, toV: Version,
comparing: boolean, comparing: boolean,
updates: HistoryContextValue['updates'] updates: LoadedUpdate[]
) { ) {
let fileToSelect: Nullable<FileDiff> = null let fileToSelect: Nullable<FileDiff> = null

View file

@ -60,6 +60,21 @@ history-root {
overflow-y: auto; overflow-y: auto;
} }
.history-all-versions-scroller {
overflow-y: auto;
height: 100%;
}
.history-all-versions-container {
position: relative;
}
.history-versions-bottom {
position: absolute;
height: 8em;
bottom: 0;
}
.history-toggle-switch-container, .history-toggle-switch-container,
.history-version-day, .history-version-day,
.history-version-details { .history-version-details {
@ -166,6 +181,23 @@ history-root {
} }
} }
.loading {
padding-top: 10rem;
font-family: @font-family-serif;
text-align: center;
}
& > .loading {
flex: 1;
}
.history-all-versions-scroller .loading {
position: sticky;
bottom: 0;
padding: @line-height-computed / 2 0;
background-color: @gray-lightest;
}
.history-loading-panel { .history-loading-panel {
padding-top: 10rem; padding-top: 10rem;
font-family: @font-family-serif; font-family: @font-family-serif;

View file

@ -1,8 +1,8 @@
import { expect } from 'chai' import { expect } from 'chai'
import type { HistoryContextValue } from '../../../../../frontend/js/features/history/context/types/history-context-value'
import type { FileDiff } from '../../../../../frontend/js/features/history/services/types/file' import type { FileDiff } from '../../../../../frontend/js/features/history/services/types/file'
import { autoSelectFile } from '../../../../../frontend/js/features/history/utils/auto-select-file' import { autoSelectFile } from '../../../../../frontend/js/features/history/utils/auto-select-file'
import type { User } from '../../../../../frontend/js/features/history/services/types/shared' import type { User } from '../../../../../frontend/js/features/history/services/types/shared'
import { LoadedUpdate } from '../../../../../frontend/js/features/history/services/types/update'
describe('autoSelectFile', function () { describe('autoSelectFile', function () {
const historyUsers: User[] = [ const historyUsers: User[] = [
@ -40,7 +40,7 @@ describe('autoSelectFile', function () {
}, },
] ]
const updates: HistoryContextValue['updates'] = [ const updates: LoadedUpdate[] = [
{ {
fromV: 25, fromV: 25,
toV: 26, toV: 26,
@ -284,7 +284,7 @@ describe('autoSelectFile', function () {
}, },
] ]
const updates: HistoryContextValue['updates'] = [ const updates: LoadedUpdate[] = [
{ {
fromV: 0, fromV: 0,
toV: 4, toV: 4,
@ -349,7 +349,7 @@ describe('autoSelectFile', function () {
}, },
] ]
const updates: HistoryContextValue['updates'] = [ const updates: LoadedUpdate[] = [
{ {
fromV: 4, fromV: 4,
toV: 7, toV: 7,
@ -444,7 +444,7 @@ describe('autoSelectFile', function () {
}, },
] ]
const updates: HistoryContextValue['updates'] = [ const updates: LoadedUpdate[] = [
{ {
fromV: 9, fromV: 9,
toV: 11, toV: 11,
@ -604,7 +604,7 @@ describe('autoSelectFile', function () {
}, },
] ]
const updates: HistoryContextValue['updates'] = [ const updates: LoadedUpdate[] = [
{ {
fromV: 7, fromV: 7,
toV: 8, toV: 8,