mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge pull request #12549 from overleaf/ii-history-react-list-of-all-versions
[web] All versions of history entries GitOrigin-RevId: 7365ac4913c115b3b2872a3713d893463719c15e
This commit is contained in:
parent
fb1f61434a
commit
8e0aa685ce
24 changed files with 735 additions and 57 deletions
|
@ -38,6 +38,7 @@
|
|||
"all_projects": "",
|
||||
"also": "",
|
||||
"an_error_occurred_when_verifying_the_coupon_code": "",
|
||||
"anonymous": "",
|
||||
"anyone_with_link_can_edit": "",
|
||||
"anyone_with_link_can_view": "",
|
||||
"approaching_compile_timeout_limit_upgrade_for_more_compile_time": "",
|
||||
|
@ -246,6 +247,10 @@
|
|||
"faster_compiles_feedback_seems_same": "",
|
||||
"faster_compiles_feedback_seems_slower": "",
|
||||
"faster_compiles_feedback_thanks": "",
|
||||
"file_action_created": "",
|
||||
"file_action_deleted": "",
|
||||
"file_action_edited": "",
|
||||
"file_action_renamed": "",
|
||||
"file_already_exists": "",
|
||||
"file_already_exists_in_this_location": "",
|
||||
"file_name": "",
|
||||
|
@ -347,6 +352,12 @@
|
|||
"hide_document_preamble": "",
|
||||
"hide_outline": "",
|
||||
"history": "",
|
||||
"history_entry_origin_dropbox": "",
|
||||
"history_entry_origin_git": "",
|
||||
"history_entry_origin_github": "",
|
||||
"history_entry_origin_upload": "",
|
||||
"history_label_created_by": "",
|
||||
"history_label_project_current_state": "",
|
||||
"history_view_a11y_description": "",
|
||||
"history_view_all": "",
|
||||
"history_view_labels": "",
|
||||
|
@ -551,6 +562,7 @@
|
|||
"other_output_files": "",
|
||||
"overall_theme": "",
|
||||
"overleaf": "",
|
||||
"overleaf_history_system": "",
|
||||
"overleaf_labs": "",
|
||||
"overwrite": "",
|
||||
"owned_by_x": "",
|
||||
|
@ -947,6 +959,7 @@
|
|||
"x_price_per_year": "",
|
||||
"year": "",
|
||||
"yes_move_me_to_personal_plan": "",
|
||||
"you": "",
|
||||
"you_already_have_a_subscription": "",
|
||||
"you_are_a_manager_and_member_of_x_plan_as_member_of_group_subscription_y_administered_by_z": "",
|
||||
"you_are_a_manager_of_commons_at_institution_x": "",
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
import { Fragment } from 'react'
|
||||
import { useHistoryContext } from '../../context/history-context'
|
||||
import HistoryVersion from './history-version'
|
||||
|
||||
function AllHistoryList() {
|
||||
const { updates } = useHistoryContext()
|
||||
|
||||
return (
|
||||
<>
|
||||
{updates.map((update, index) => (
|
||||
<Fragment key={`${update.fromV}_${update.toV}`}>
|
||||
{update.meta.first_in_day && index > 0 && (
|
||||
<hr className="history-version-divider" />
|
||||
)}
|
||||
<HistoryVersion update={update} />
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AllHistoryList
|
|
@ -1,10 +1,11 @@
|
|||
import usePersistedState from '../../../../shared/hooks/use-persisted-state'
|
||||
import ToggleSwitch from './toggle-switch'
|
||||
import Main from './main'
|
||||
import AllHistoryList from './all-history-list'
|
||||
import LabelsList from './labels-list'
|
||||
import { useHistoryContext } from '../../context/history-context'
|
||||
|
||||
function ChangeList() {
|
||||
const { projectId, isError } = useHistoryContext()
|
||||
const { projectId, error } = useHistoryContext()
|
||||
const [labelsOnly, setLabelsOnly] = usePersistedState(
|
||||
`history.userPrefs.showOnlyLabels.${projectId}`,
|
||||
false
|
||||
|
@ -12,14 +13,16 @@ function ChangeList() {
|
|||
|
||||
return (
|
||||
<aside className="change-list">
|
||||
<div className="history-header toggle-switch-container">
|
||||
{!isError && (
|
||||
<div className="history-header history-toggle-switch-container">
|
||||
{!error && (
|
||||
<ToggleSwitch labelsOnly={labelsOnly} setLabelsOnly={setLabelsOnly} />
|
||||
)}
|
||||
</div>
|
||||
{!error && (
|
||||
<div className="version-list-container">
|
||||
<Main />
|
||||
{labelsOnly ? <LabelsList /> : <AllHistoryList />}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { getProjectOpDoc } from '../../utils/history-details'
|
||||
import { LoadedUpdate } from '../../services/types/update'
|
||||
|
||||
type ChangesProps = {
|
||||
pathNames: LoadedUpdate['pathnames']
|
||||
projectOps: LoadedUpdate['project_ops']
|
||||
}
|
||||
|
||||
function Changes({ pathNames, projectOps }: ChangesProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<ol className="history-version-changes">
|
||||
{pathNames.map(pathName => (
|
||||
<li key={pathName}>
|
||||
<div className="history-version-change-action">
|
||||
{t('file_action_edited')}
|
||||
</div>
|
||||
<div className="history-version-change-doc">{pathName}</div>
|
||||
</li>
|
||||
))}
|
||||
{projectOps.map((op, index) => (
|
||||
<li key={index}>
|
||||
<div className="history-version-change-action">
|
||||
{op.rename && t('file_action_renamed')}
|
||||
{op.add && t('file_action_created')}
|
||||
{op.remove && t('file_action_deleted')}
|
||||
</div>
|
||||
<div className="history-version-change-doc">
|
||||
{getProjectOpDoc(op)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
export default Changes
|
|
@ -0,0 +1,49 @@
|
|||
import LabelBadges from './label-badges'
|
||||
import Changes from './changes'
|
||||
import MetadataUsersList from './metadata-users-list'
|
||||
import Origin from './origin'
|
||||
import { useUserContext } from '../../../../shared/context/user-context'
|
||||
import { relativeDate, formatTime } from '../../../utils/format-date'
|
||||
import { LoadedUpdate } from '../../services/types/update'
|
||||
|
||||
type HistoryEntryProps = {
|
||||
update: LoadedUpdate
|
||||
}
|
||||
|
||||
function HistoryVersion({ update }: HistoryEntryProps) {
|
||||
const { id: currentUserId } = useUserContext()
|
||||
|
||||
return (
|
||||
<div>
|
||||
{update.meta.first_in_day && (
|
||||
<time className="history-version-day">
|
||||
{relativeDate(update.meta.end_ts)}
|
||||
</time>
|
||||
)}
|
||||
<div className="history-version-details">
|
||||
<div>
|
||||
<time className="history-version-metadata-time">
|
||||
{formatTime(update.meta.end_ts, 'Do MMMM, h:mm a')}
|
||||
</time>
|
||||
<LabelBadges
|
||||
labels={update.labels}
|
||||
showTooltip
|
||||
currentUserId={currentUserId}
|
||||
/>
|
||||
<Changes
|
||||
pathNames={update.pathnames}
|
||||
projectOps={update.project_ops}
|
||||
/>
|
||||
<MetadataUsersList
|
||||
users={update.meta.users}
|
||||
origin={update.meta.origin}
|
||||
currentUserId={currentUserId}
|
||||
/>
|
||||
<Origin origin={update.meta.origin} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HistoryVersion
|
|
@ -0,0 +1,96 @@
|
|||
import { Fragment } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Icon from '../../../../shared/components/icon'
|
||||
import Tooltip from '../../../../shared/components/tooltip'
|
||||
import { useHistoryContext } from '../../context/history-context'
|
||||
import { isPseudoLabel } from '../../utils/label'
|
||||
import { formatDate } from '../../../../utils/dates'
|
||||
import { orderBy } from 'lodash'
|
||||
import { Label, PseudoCurrentStateLabel } from '../../services/types/update'
|
||||
|
||||
type LabelBadgesProps = {
|
||||
showTooltip: boolean
|
||||
currentUserId: string
|
||||
labels: Array<Label | PseudoCurrentStateLabel>
|
||||
}
|
||||
|
||||
function LabelBadges({ labels, currentUserId, showTooltip }: LabelBadgesProps) {
|
||||
const { t } = useTranslation()
|
||||
const { labels: allLabels } = useHistoryContext()
|
||||
const orderedLabels = orderBy(labels, ['created_at'], ['desc'])
|
||||
|
||||
const handleDelete = (e: React.MouseEvent, label: Label) => {
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{orderedLabels.map(label => {
|
||||
const isPseudoCurrentStateLabel = isPseudoLabel(label)
|
||||
const isOwnedByCurrentUser = !isPseudoCurrentStateLabel
|
||||
? label.user_id === currentUserId
|
||||
: null
|
||||
const currentLabelData = allLabels?.find(({ id }) => id === label.id)
|
||||
const labelOwnerName =
|
||||
currentLabelData && !isPseudoLabel(currentLabelData)
|
||||
? currentLabelData.user_display_name
|
||||
: t('anonymous')
|
||||
|
||||
const badgeContent = (
|
||||
<span className="history-version-badge">
|
||||
<Icon type="tag" fw />
|
||||
<span className="history-version-badge-comment">
|
||||
{isPseudoCurrentStateLabel
|
||||
? t('history_label_project_current_state')
|
||||
: label.comment}
|
||||
</span>
|
||||
{isOwnedByCurrentUser && !isPseudoCurrentStateLabel && (
|
||||
<button
|
||||
type="button"
|
||||
className="history-version-label-delete-btn"
|
||||
onClick={e => handleDelete(e, label)}
|
||||
aria-label={t('delete')}
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
|
||||
return (
|
||||
<Fragment key={label.id}>
|
||||
{showTooltip && !isPseudoCurrentStateLabel ? (
|
||||
<Tooltip
|
||||
key={label.id}
|
||||
description={
|
||||
<div className="history-version-label-tooltip">
|
||||
<div className="history-version-label-tooltip-row">
|
||||
<b>
|
||||
<Icon type="tag" fw />
|
||||
{label.comment}
|
||||
</b>
|
||||
</div>
|
||||
<div className="history-version-label-tooltip-row">
|
||||
{t('history_label_created_by')} {labelOwnerName}
|
||||
</div>
|
||||
<div className="history-version-label-tooltip-row">
|
||||
<time>{formatDate(label.created_at)}</time>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
id={label.id}
|
||||
overlayProps={{ placement: 'left' }}
|
||||
>
|
||||
{badgeContent}
|
||||
</Tooltip>
|
||||
) : (
|
||||
badgeContent
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default LabelBadges
|
|
@ -0,0 +1,5 @@
|
|||
function LabelsList() {
|
||||
return <div>Labels only</div>
|
||||
}
|
||||
|
||||
export default LabelsList
|
|
@ -1,5 +0,0 @@
|
|||
function Main() {
|
||||
return <div>Change List</div>
|
||||
}
|
||||
|
||||
export default Main
|
|
@ -0,0 +1,54 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { getUserColor, formatUserName } from '../../utils/history-details'
|
||||
import { LoadedUpdate } from '../../services/types/update'
|
||||
|
||||
type MetadataUsersListProps = {
|
||||
currentUserId: string
|
||||
} & Pick<LoadedUpdate['meta'], 'users' | 'origin'>
|
||||
|
||||
function MetadataUsersList({
|
||||
users,
|
||||
origin,
|
||||
currentUserId,
|
||||
}: MetadataUsersListProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<ol className="history-version-metadata-users">
|
||||
{users.map((user, index) => {
|
||||
let userName: string
|
||||
if (!user) {
|
||||
userName = t('anonymous')
|
||||
} else if (user?.id === currentUserId) {
|
||||
userName = t('you')
|
||||
} else {
|
||||
userName = formatUserName(user)
|
||||
}
|
||||
|
||||
return (
|
||||
<li key={index}>
|
||||
<span
|
||||
className="history-version-user-badge-color"
|
||||
style={{ backgroundColor: getUserColor(user) }}
|
||||
/>
|
||||
{userName}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
{!users.length && (
|
||||
<li>
|
||||
<span
|
||||
className="history-version-user-badge-color"
|
||||
style={{ backgroundColor: getUserColor() }}
|
||||
/>
|
||||
{origin?.kind === 'history-resync' ||
|
||||
origin?.kind === 'history-migration'
|
||||
? t('overleaf_history_system')
|
||||
: t('anonymous')}
|
||||
</li>
|
||||
)}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
export default MetadataUsersList
|
|
@ -0,0 +1,19 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { LoadedUpdate } from '../../services/types/update'
|
||||
|
||||
function Origin({ origin }: Pick<LoadedUpdate['meta'], 'origin'>) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
let result: string | null = null
|
||||
if (origin?.kind === 'dropbox') result = t('history_entry_origin_dropbox')
|
||||
if (origin?.kind === 'upload') result = t('history_entry_origin_upload')
|
||||
if (origin?.kind === 'git-bridge') result = t('history_entry_origin_git')
|
||||
if (origin?.kind === 'github') result = t('history_entry_origin_github')
|
||||
|
||||
if (result) {
|
||||
return <span className="history-version-origin">({result})</span>
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export default Origin
|
|
@ -1,30 +1,52 @@
|
|||
import { createContext, useContext, useEffect, useState, useMemo } from 'react'
|
||||
import { useIdeContext } from '../../../shared/context/ide-context'
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import { useLayoutContext } from '../../../shared/context/layout-context'
|
||||
import useAsync from '../../../shared/hooks/use-async'
|
||||
import { useUserContext } from '../../../shared/context/user-context'
|
||||
import { useProjectContext } from '../../../shared/context/project-context'
|
||||
import { HistoryContextValue } from './types/history-context-value'
|
||||
import { Update, UpdateSelection } from '../services/types/update'
|
||||
import { FileSelection } from '../services/types/file'
|
||||
import { diffFiles, fetchUpdates } from '../services/api'
|
||||
import { diffFiles, fetchLabels, fetchUpdates } from '../services/api'
|
||||
import { renamePathnameKey, isFileRenamed } from '../utils/file-tree'
|
||||
import { loadLabels } from '../utils/label'
|
||||
import ColorManager from '../../../ide/colors/ColorManager'
|
||||
import moment from 'moment'
|
||||
import * as eventTracking from '../../../infrastructure/event-tracking'
|
||||
import { cloneDeep } from 'lodash'
|
||||
import { LoadedUpdate, Update, UpdateSelection } from '../services/types/update'
|
||||
import { FileSelection } from '../services/types/file'
|
||||
import { Nullable } from '../../../../../types/utils'
|
||||
|
||||
function useHistory() {
|
||||
const { view } = useLayoutContext()
|
||||
const ide = useIdeContext()
|
||||
const projectId = ide.project_id
|
||||
const user = useUserContext()
|
||||
const project = useProjectContext()
|
||||
const userId = user.id
|
||||
const projectId = project._id
|
||||
const projectOwnerId = project.owner?._id
|
||||
const [updateSelection, setUpdateSelection] =
|
||||
useState<UpdateSelection | null>(null)
|
||||
const [fileSelection, setFileSelection] = useState<FileSelection | null>(null)
|
||||
/* eslint-disable no-unused-vars */
|
||||
const [viewMode, setViewMode] = useState<HistoryContextValue['viewMode']>('')
|
||||
const [updates, setUpdates] = useState<LoadedUpdate[]>([])
|
||||
const [loadingFileTree, setLoadingFileTree] =
|
||||
useState<HistoryContextValue['loadingFileTree']>(true)
|
||||
const [nextBeforeTimestamp, setNextBeforeTimestamp] =
|
||||
useState<HistoryContextValue['nextBeforeTimestamp']>(null)
|
||||
useState<HistoryContextValue['nextBeforeTimestamp']>()
|
||||
const [atEnd, setAtEnd] = useState<HistoryContextValue['atEnd']>(false)
|
||||
const [userHasFullFeature, setUserHasFullFeature] =
|
||||
useState<HistoryContextValue['userHasFullFeature']>(undefined)
|
||||
const [freeHistoryLimitHit, setFreeHistoryLimitHit] =
|
||||
useState<HistoryContextValue['freeHistoryLimitHit']>(false)
|
||||
const [labels, setLabels] = useState<HistoryContextValue['labels']>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
/* eslint-disable no-unused-vars */
|
||||
const [viewMode, setViewMode] = useState<HistoryContextValue['viewMode']>('')
|
||||
const [userHasFullFeature, setUserHasFullFeature] =
|
||||
useState<HistoryContextValue['userHasFullFeature']>(undefined)
|
||||
const [selection, setSelection] = useState<HistoryContextValue['selection']>({
|
||||
docs: {},
|
||||
pathname: null,
|
||||
|
@ -42,18 +64,110 @@ function useHistory() {
|
|||
})
|
||||
/* eslint-enable no-unused-vars */
|
||||
|
||||
const { isLoading, isError, error, data, runAsync } = useAsync<{
|
||||
updates: Update[]
|
||||
}>()
|
||||
const updates = useMemo(() => data?.updates ?? [], [data?.updates])
|
||||
const loadingFileTree = true
|
||||
const fetchNextBatchOfUpdates = useCallback(() => {
|
||||
const loadUpdates = (updatesData: Update[]) => {
|
||||
const dateTimeNow = new Date()
|
||||
const timestamp24hoursAgo = dateTimeNow.setDate(dateTimeNow.getDate() - 1)
|
||||
let previousUpdate = updates[updates.length - 1]
|
||||
let cutOffIndex: Nullable<number> = null
|
||||
|
||||
let loadedUpdates: LoadedUpdate[] = cloneDeep(updatesData)
|
||||
for (const [index, update] of loadedUpdates.entries()) {
|
||||
for (const user of update.meta.users) {
|
||||
if (user) {
|
||||
user.hue = ColorManager.getHueForUserId(user.id)
|
||||
}
|
||||
}
|
||||
if (
|
||||
!previousUpdate ||
|
||||
!moment(previousUpdate.meta.end_ts).isSame(update.meta.end_ts, 'day')
|
||||
) {
|
||||
update.meta.first_in_day = true
|
||||
}
|
||||
|
||||
previousUpdate = update
|
||||
|
||||
if (userHasFullFeature && update.meta.end_ts < timestamp24hoursAgo) {
|
||||
cutOffIndex = index || 1 // Make sure that we show at least one entry (to allow labelling).
|
||||
setFreeHistoryLimitHit(true)
|
||||
if (projectOwnerId === userId) {
|
||||
eventTracking.send(
|
||||
'subscription-funnel',
|
||||
'editor-click-feature',
|
||||
'history'
|
||||
)
|
||||
eventTracking.sendMB('paywall-prompt', {
|
||||
'paywall-type': 'history',
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!userHasFullFeature && cutOffIndex != null) {
|
||||
loadedUpdates = loadedUpdates.slice(0, cutOffIndex)
|
||||
}
|
||||
|
||||
setUpdates(updates.concat(loadedUpdates))
|
||||
|
||||
// TODO first load
|
||||
}
|
||||
|
||||
if (atEnd) return
|
||||
|
||||
const updatesPromise = fetchUpdates(projectId, nextBeforeTimestamp)
|
||||
const labelsPromise = labels == null ? fetchLabels(projectId) : null
|
||||
|
||||
setIsLoading(true)
|
||||
Promise.all([updatesPromise, labelsPromise])
|
||||
.then(([{ updates: updatesData, nextBeforeTimestamp }, labels]) => {
|
||||
const lastUpdateToV = updatesData.length ? updatesData[0].toV : null
|
||||
|
||||
if (labels) {
|
||||
setLabels(loadLabels(labels, lastUpdateToV))
|
||||
}
|
||||
|
||||
loadUpdates(updatesData)
|
||||
setNextBeforeTimestamp(nextBeforeTimestamp)
|
||||
if (
|
||||
nextBeforeTimestamp == null ||
|
||||
freeHistoryLimitHit ||
|
||||
!updates.length
|
||||
) {
|
||||
setAtEnd(true)
|
||||
}
|
||||
if (!updates.length) {
|
||||
setLoadingFileTree(false)
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
setError(error)
|
||||
setAtEnd(true)
|
||||
setLoadingFileTree(false)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
}, [
|
||||
atEnd,
|
||||
freeHistoryLimitHit,
|
||||
labels,
|
||||
nextBeforeTimestamp,
|
||||
projectId,
|
||||
projectOwnerId,
|
||||
userId,
|
||||
userHasFullFeature,
|
||||
updates,
|
||||
])
|
||||
|
||||
// Initial load when the History tab is active
|
||||
const initialFetch = useRef(false)
|
||||
useEffect(() => {
|
||||
if (view === 'history') {
|
||||
runAsync(fetchUpdates(projectId)).catch(console.error)
|
||||
if (view === 'history' && !initialFetch.current) {
|
||||
fetchNextBatchOfUpdates()
|
||||
initialFetch.current = true
|
||||
}
|
||||
}, [view, projectId, runAsync])
|
||||
}, [view, fetchNextBatchOfUpdates])
|
||||
|
||||
// Load files when the update selection changes
|
||||
useEffect(() => {
|
||||
|
@ -87,7 +201,6 @@ function useHistory() {
|
|||
() => ({
|
||||
atEnd,
|
||||
error,
|
||||
isError,
|
||||
isLoading,
|
||||
freeHistoryLimitHit,
|
||||
labels,
|
||||
|
@ -106,7 +219,6 @@ function useHistory() {
|
|||
[
|
||||
atEnd,
|
||||
error,
|
||||
isError,
|
||||
isLoading,
|
||||
freeHistoryLimitHit,
|
||||
labels,
|
||||
|
|
|
@ -1,20 +1,24 @@
|
|||
import { Nullable } from '../../../../../../types/utils'
|
||||
import { Update, UpdateSelection } from '../../services/types/update'
|
||||
import {
|
||||
Label,
|
||||
LoadedUpdate,
|
||||
PseudoCurrentStateLabel,
|
||||
UpdateSelection,
|
||||
} from '../../services/types/update'
|
||||
import { Selection } from '../../../../../../types/history/selection'
|
||||
import { FileSelection } from '../../services/types/file'
|
||||
|
||||
export type HistoryContextValue = {
|
||||
updates: Update[]
|
||||
updates: LoadedUpdate[]
|
||||
viewMode: string
|
||||
nextBeforeTimestamp: Nullable<number>
|
||||
nextBeforeTimestamp: number | undefined
|
||||
atEnd: boolean
|
||||
userHasFullFeature: boolean | undefined
|
||||
freeHistoryLimitHit: boolean
|
||||
selection: Selection
|
||||
isError: boolean
|
||||
isLoading: boolean
|
||||
error: Nullable<unknown>
|
||||
labels: Nullable<unknown>
|
||||
labels: Nullable<Array<Label | PseudoCurrentStateLabel>>
|
||||
loadingFileTree: boolean
|
||||
projectId: string
|
||||
fileSelection: FileSelection | null
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { getJSON } from '../../../infrastructure/fetch-json'
|
||||
import { FileDiff } from './types/file'
|
||||
import { Update } from './types/update'
|
||||
import { Label, Update } from './types/update'
|
||||
import { DocDiffResponse } from './types/doc'
|
||||
|
||||
const BATCH_SIZE = 10
|
||||
|
@ -16,7 +16,14 @@ export function fetchUpdates(projectId: string, before?: number) {
|
|||
|
||||
const queryParamsSerialized = new URLSearchParams(queryParams).toString()
|
||||
const updatesURL = `/project/${projectId}/updates?${queryParamsSerialized}`
|
||||
return getJSON<{ updates: Update[] }>(updatesURL)
|
||||
return getJSON<{ updates: Update[]; nextBeforeTimestamp?: number }>(
|
||||
updatesURL
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchLabels(projectId: string) {
|
||||
const labelsURL = `/project/${projectId}/labels`
|
||||
return getJSON<Label[]>(labelsURL)
|
||||
}
|
||||
|
||||
export function diffFiles(projectId: string, fromV: number, toV: number) {
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { Nullable } from '../../../../../../types/utils'
|
||||
|
||||
export interface User {
|
||||
first_name: string
|
||||
last_name: string
|
||||
|
@ -6,7 +8,16 @@ export interface User {
|
|||
}
|
||||
|
||||
export interface Meta {
|
||||
users: User[]
|
||||
users: Nullable<User>[]
|
||||
start_ts: number
|
||||
end_ts: number
|
||||
origin?: {
|
||||
kind:
|
||||
| 'dropbox'
|
||||
| 'upload'
|
||||
| 'git-bridge'
|
||||
| 'github'
|
||||
| 'history-resync'
|
||||
| 'history-migration'
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { Meta } from './shared'
|
||||
import { Meta, User } from './shared'
|
||||
import { Nullable } from '../../../../../../types/utils'
|
||||
|
||||
interface UpdateLabel {
|
||||
id: string
|
||||
|
@ -8,11 +9,18 @@ interface UpdateLabel {
|
|||
created_at: string
|
||||
}
|
||||
|
||||
interface Label extends UpdateLabel {
|
||||
export interface Label extends UpdateLabel {
|
||||
user_display_name: string
|
||||
}
|
||||
|
||||
interface ProjectOp {
|
||||
export interface PseudoCurrentStateLabel {
|
||||
id: '1'
|
||||
isPseudoCurrentStateLabel: true
|
||||
version: Nullable<number>
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ProjectOp {
|
||||
add?: { pathname: string }
|
||||
rename?: { pathname: string; newPathname: string }
|
||||
remove?: { pathname: string }
|
||||
|
@ -23,11 +31,26 @@ export interface Update {
|
|||
fromV: number
|
||||
toV: number
|
||||
meta: Meta
|
||||
labels?: Label[]
|
||||
labels: Label[]
|
||||
pathnames: string[]
|
||||
project_ops: ProjectOp[]
|
||||
}
|
||||
|
||||
interface LoadedUpdateMetaUser extends User {
|
||||
hue?: number
|
||||
}
|
||||
|
||||
export type LoadedUpdateMetaUsers = Nullable<LoadedUpdateMetaUser>[]
|
||||
|
||||
interface LoadedUpdateMeta extends Meta {
|
||||
first_in_day?: true
|
||||
users: LoadedUpdateMetaUsers
|
||||
}
|
||||
|
||||
export interface LoadedUpdate extends Update {
|
||||
meta: LoadedUpdateMeta
|
||||
}
|
||||
|
||||
export interface UpdateSelection {
|
||||
update: Update
|
||||
comparing: boolean
|
||||
|
|
|
@ -33,7 +33,7 @@ export function highlightsFromDiffResponse(chunks: DocDiffChunk[]) {
|
|||
// There doesn't seem to be a convenient way to make this translatable
|
||||
label: `Added by ${name} on ${date}`,
|
||||
range,
|
||||
hue: ColorManager.getHueForUserId(user.id),
|
||||
hue: ColorManager.getHueForUserId(user?.id),
|
||||
})
|
||||
} else if (isDeletion) {
|
||||
highlights.push({
|
||||
|
@ -41,7 +41,7 @@ export function highlightsFromDiffResponse(chunks: DocDiffChunk[]) {
|
|||
// There doesn't seem to be a convenient way to make this translatable
|
||||
label: `Deleted by ${name} on ${date}`,
|
||||
range,
|
||||
hue: ColorManager.getHueForUserId(user.id),
|
||||
hue: ColorManager.getHueForUserId(user?.id),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
import ColorManager from '../../../ide/colors/ColorManager'
|
||||
import { Nullable } from '../../../../../types/utils'
|
||||
import { User } from '../services/types/shared'
|
||||
import { ProjectOp } from '../services/types/update'
|
||||
|
||||
export const getUserColor = (
|
||||
user?: Nullable<{ id: string; _id?: never } | { id?: never; _id: string }>
|
||||
) => {
|
||||
const curUserId = user?.id || user?._id
|
||||
const hue = ColorManager.getHueForUserId(curUserId) || 100
|
||||
|
||||
return `hsl(${hue}, 70%, 50%)`
|
||||
}
|
||||
|
||||
export const formatUserName = (user: User) => {
|
||||
let name = [user.first_name, user.last_name]
|
||||
.filter(n => n != null)
|
||||
.join(' ')
|
||||
.trim()
|
||||
if (name === '') {
|
||||
name = user.email.split('@')[0]
|
||||
}
|
||||
if (name == null || name === '') {
|
||||
return '?'
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
export const getProjectOpDoc = (projectOp: ProjectOp) => {
|
||||
if (projectOp.rename) {
|
||||
return `${projectOp.rename.pathname} → ${projectOp.rename.newPathname}`
|
||||
}
|
||||
if (projectOp.add) {
|
||||
return `${projectOp.add.pathname}`
|
||||
}
|
||||
if (projectOp.remove) {
|
||||
return `${projectOp.remove.pathname}`
|
||||
}
|
||||
return ''
|
||||
}
|
60
services/web/frontend/js/features/history/utils/label.ts
Normal file
60
services/web/frontend/js/features/history/utils/label.ts
Normal file
|
@ -0,0 +1,60 @@
|
|||
import { orderBy } from 'lodash'
|
||||
import { Label, PseudoCurrentStateLabel } from '../services/types/update'
|
||||
import { Nullable } from '../../../../../types/utils'
|
||||
|
||||
export const isPseudoLabel = (
|
||||
label: Label | PseudoCurrentStateLabel
|
||||
): label is PseudoCurrentStateLabel => {
|
||||
return (label as PseudoCurrentStateLabel).isPseudoCurrentStateLabel === true
|
||||
}
|
||||
|
||||
const sortLabelsByVersionAndDate = (
|
||||
labels: Array<Label | PseudoCurrentStateLabel>
|
||||
) => {
|
||||
return orderBy(
|
||||
labels,
|
||||
['isPseudoCurrentStateLabel', 'version', 'created_at'],
|
||||
['asc', 'desc', 'desc']
|
||||
)
|
||||
}
|
||||
|
||||
const deletePseudoCurrentStateLabelIfExistent = (
|
||||
labels: Array<Label | PseudoCurrentStateLabel>
|
||||
) => {
|
||||
if (labels.length && isPseudoLabel(labels[0])) {
|
||||
const [, ...rest] = labels
|
||||
return rest
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
const addPseudoCurrentStateLabelIfNeeded = (
|
||||
labels: Array<Label | PseudoCurrentStateLabel>,
|
||||
mostRecentVersion: Nullable<number>
|
||||
) => {
|
||||
if (!labels.length || labels[0].version !== mostRecentVersion) {
|
||||
const pseudoCurrentStateLabel: PseudoCurrentStateLabel = {
|
||||
id: '1',
|
||||
isPseudoCurrentStateLabel: true,
|
||||
version: mostRecentVersion,
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
return [pseudoCurrentStateLabel, ...labels]
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
export const loadLabels = (
|
||||
labels: Label[],
|
||||
lastUpdateToV: Nullable<number>
|
||||
) => {
|
||||
const sortedLabels = sortLabelsByVersionAndDate(labels)
|
||||
const labelsWithoutPseudoLabel =
|
||||
deletePseudoCurrentStateLabelIfExistent(sortedLabels)
|
||||
const labelsWithPseudoLabelIfNeeded = addPseudoCurrentStateLabelIfNeeded(
|
||||
labelsWithoutPseudoLabel,
|
||||
lastUpdateToV
|
||||
)
|
||||
|
||||
return labelsWithPseudoLabelIfNeeded
|
||||
}
|
|
@ -11,8 +11,8 @@ moment.updateLocale('en', {
|
|||
},
|
||||
})
|
||||
|
||||
export function formatTime(date) {
|
||||
return moment(date).format('h:mm a')
|
||||
export function formatTime(date, format = 'h:mm a') {
|
||||
return moment(date).format(format)
|
||||
}
|
||||
|
||||
export function relativeDate(date) {
|
||||
|
|
|
@ -31,7 +31,7 @@ export default {
|
|||
(Story: React.ComponentType) => (
|
||||
<div className="history-react">
|
||||
<div className="change-list">
|
||||
<div className="history-header toggle-switch-container">
|
||||
<div className="history-header history-toggle-switch-container">
|
||||
<Story />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -15,7 +15,7 @@ history-root {
|
|||
height: @history-toolbar-height;
|
||||
background-color: @history-react-header-bg;
|
||||
color: @history-react-header-color;
|
||||
font-size: 14px;
|
||||
font-size: @font-size-small;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
@ -50,12 +50,9 @@ history-root {
|
|||
|
||||
.change-list {
|
||||
width: @versions-list-width;
|
||||
font-size: @font-size-small;
|
||||
border-left: 1px solid @history-react-separator-color;
|
||||
box-sizing: content-box;
|
||||
|
||||
.toggle-switch-container {
|
||||
padding: 0 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-switch-label {
|
||||
|
@ -66,6 +63,112 @@ history-root {
|
|||
}
|
||||
}
|
||||
|
||||
.history-toggle-switch-container,
|
||||
.history-version-day,
|
||||
.history-version-details {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.history-version-day {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: block;
|
||||
padding-top: 12px;
|
||||
padding-bottom: 4px;
|
||||
line-height: 20px;
|
||||
background-color: @white;
|
||||
}
|
||||
|
||||
.history-version-details {
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
background-color: @neutral-10;
|
||||
}
|
||||
}
|
||||
|
||||
.history-version-metadata-time {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-weight: bold;
|
||||
color: @neutral-90;
|
||||
}
|
||||
|
||||
.history-version-metadata-users,
|
||||
.history-version-changes {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.history-version-metadata-users {
|
||||
display: inline;
|
||||
|
||||
> li {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.history-version-changes {
|
||||
> li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.history-version-user-badge-color {
|
||||
@size: 8px;
|
||||
display: inline-block;
|
||||
width: @size;
|
||||
height: @size;
|
||||
margin-right: 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.history-version-day,
|
||||
.history-version-change-action,
|
||||
.history-version-metadata-users,
|
||||
.history-version-origin {
|
||||
color: @neutral-70;
|
||||
}
|
||||
|
||||
.history-version-change-doc {
|
||||
color: @neutral-90;
|
||||
}
|
||||
|
||||
.history-version-divider {
|
||||
margin: 6px 8px;
|
||||
border-color: @neutral-20;
|
||||
}
|
||||
|
||||
.history-version-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
height: 24px;
|
||||
margin-bottom: 3px;
|
||||
margin-right: 10px;
|
||||
padding: 4px;
|
||||
white-space: nowrap;
|
||||
color: @ol-blue-gray-6;
|
||||
background-color: @neutral-20;
|
||||
border-radius: 4px;
|
||||
|
||||
&-comment {
|
||||
margin-left: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.history-version-label-delete-btn {
|
||||
.reset-button;
|
||||
marigin-left: 8px;
|
||||
padding: 4px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.history-loading-panel {
|
||||
padding-top: 10rem;
|
||||
font-family: @font-family-serif;
|
||||
|
@ -85,3 +188,16 @@ history-root {
|
|||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.history-version-label-tooltip {
|
||||
padding: 6px;
|
||||
text-align: initial;
|
||||
|
||||
&-row {
|
||||
margin-bottom: 6.25px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: initial;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1317,3 +1317,11 @@
|
|||
outline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.reset-button {
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
@neutral-90: #1b222c;
|
||||
@neutral-40: #afb5c0;
|
||||
@neutral-10: #f4f5f6;
|
||||
@neutral-70: #495365;
|
||||
@neutral-80: #2f3a4c;
|
||||
|
||||
// Styleguide colors
|
||||
|
|
|
@ -1681,6 +1681,7 @@
|
|||
"year": "year",
|
||||
"yes_move_me_to_personal_plan": "Yes, move me to the Personal plan",
|
||||
"yes_that_is_correct": "Yes, that’s correct",
|
||||
"you": "You",
|
||||
"you_already_have_a_subscription": "You already have a subscription",
|
||||
"you_and_collaborators_get_access_to": "You and your project collaborators get access to",
|
||||
"you_and_collaborators_get_access_to_info": "These features are available to you and your collaborators (other Overleaf users that you invite to your projects).",
|
||||
|
|
Loading…
Reference in a new issue