Implement history react toolbar UI (#12530)

There are two different UI in this PR: `comparing` and `viewing` mode.

- For `comparing`, the user would be shown two separate date. It uses the `UpdateRange` object and this PR adds a timestamp to both `fromV` and `toV` of the object type.
- For `viewing`, the user would only be shown one date since `viewing` mode means viewing a specific update version.

Some other notable changes:

- Move `diff` state to `diff-view.tsx`, which contains `main.tsx` (main editor history view) and `toolbar.tsx` as its children
- refactor `autoSelectFile` by passing `updateRange.toV` directly
- refactor `updateIsSelected` by passing an object that contains `fromV` and `toV` instead of passing `update

There's also a cypress test for both the `viewing` mode and `comparing` mode in this PR.

GitOrigin-RevId: ba54f073f3479c55a39eb6b2932ea7faff78dddc
This commit is contained in:
M Fahru 2023-04-20 08:41:44 -07:00 committed by Copybot
parent 08d2eea47a
commit 4dec157e08
14 changed files with 311 additions and 66 deletions

View file

@ -125,6 +125,7 @@
"commons_plan_tooltip": "",
"compact": "",
"company_name": "",
"comparing_x_to_y": "",
"compile_error_entry_description": "",
"compile_error_handling": "",
"compile_larger_projects": "",
@ -956,6 +957,7 @@
"view_metrics": "",
"view_pdf": "",
"view_your_invoices": "",
"viewing_x": "",
"want_change_to_apply_before_plan_end": "",
"we_cant_find_any_sections_or_subsections_in_this_file": "",
"we_logged_you_in": "",
@ -967,6 +969,8 @@
"word_count": "",
"work_offline": "",
"work_with_non_overleaf_users": "",
"x_changes_in": "",
"x_changes_in_plural": "",
"x_price_for_first_month": "",
"x_price_for_first_year": "",
"x_price_for_y_months": "",

View file

@ -20,7 +20,11 @@ function HistoryVersion({ update }: HistoryEntryProps) {
const { selection, setSelection } = useHistoryContext()
const selected = updateIsSelected(update, selection)
const selected = updateIsSelected({
fromV: update.fromV,
toV: update.toV,
selection,
})
function compare() {
const { updateRange } = selection
@ -29,9 +33,14 @@ function HistoryVersion({ update }: HistoryEntryProps) {
}
const fromV = Math.min(update.fromV, updateRange.fromV)
const toV = Math.max(update.toV, updateRange.toV)
const fromVTimestamp = Math.min(
update.meta.end_ts,
updateRange.fromVTimestamp
)
const toVTimestamp = Math.max(update.meta.end_ts, updateRange.toVTimestamp)
setSelection({
updateRange: { fromV, toV },
updateRange: { fromV, toV, fromVTimestamp, toVTimestamp },
comparing: true,
files: [],
pathname: null,
@ -52,7 +61,12 @@ function HistoryVersion({ update }: HistoryEntryProps) {
data-testid="history-version-details"
onClick={() =>
setSelection({
updateRange: update,
updateRange: {
fromV: update.fromV,
toV: update.toV,
fromVTimestamp: update.meta.end_ts,
toVTimestamp: update.meta.end_ts,
},
comparing: false,
files: [],
pathname: null,

View file

@ -1,14 +1,56 @@
import Toolbar from './toolbar'
import Main from './main'
import { Diff, DocDiffResponse } from '../../services/types/doc'
import { useEffect, useState } from 'react'
import { Nullable } from '../../../../../../types/utils'
import { useHistoryContext } from '../../context/history-context'
import { diffDoc } from '../../services/api'
import { highlightsFromDiffResponse } from '../../utils/highlights-from-diff-response'
import useAsync from '../../../../shared/hooks/use-async'
function DiffView() {
const [diff, setDiff] = useState<Nullable<Diff>>(null)
const { selection, projectId } = useHistoryContext()
const { isLoading, runAsync } = useAsync<DocDiffResponse>()
const { updateRange, pathname } = selection
useEffect(() => {
if (!updateRange || !pathname) {
return
}
const { fromV, toV } = updateRange
// TODO: Error handling
runAsync(diffDoc(projectId, fromV, toV, pathname)).then(data => {
let diff: Diff | undefined
if (!data?.diff) {
setDiff(null)
}
if ('binary' in data.diff) {
diff = { binary: true }
} else {
diff = {
binary: false,
docDiff: highlightsFromDiffResponse(data.diff),
}
}
setDiff(diff)
})
}, [projectId, runAsync, updateRange, pathname])
return (
<div className="doc-panel">
<div className="history-header toolbar-container">
<Toolbar />
<Toolbar diff={diff} selection={selection} />
</div>
<div className="doc-container">
<Main />
<Main diff={diff} isLoading={isLoading} />
</div>
</div>
)

View file

@ -1,45 +1,15 @@
import { useHistoryContext } from '../../context/history-context'
import { diffDoc } from '../../services/api'
import { useEffect } from 'react'
import { DocDiffResponse, Highlight } from '../../services/types/doc'
import { highlightsFromDiffResponse } from '../../utils/highlights-from-diff-response'
import { Nullable } from '../../../../../../types/utils'
import { Diff } from '../../services/types/doc'
import DocumentDiffViewer from './document-diff-viewer'
import useAsync from '../../../../shared/hooks/use-async'
import { useTranslation } from 'react-i18next'
type Diff = {
binary: boolean
docDiff?: {
doc: string
highlights: Highlight[]
}
type MainProps = {
diff: Nullable<Diff>
isLoading: boolean
}
function Main() {
function Main({ diff, isLoading }: MainProps) {
const { t } = useTranslation()
const { projectId, selection } = useHistoryContext()
const { isLoading, runAsync, data } = useAsync<DocDiffResponse>()
let diff: Diff | undefined
if (data?.diff) {
if ('binary' in data.diff) {
diff = { binary: true }
} else {
diff = { binary: false, docDiff: highlightsFromDiffResponse(data.diff) }
}
}
const { updateRange, pathname } = selection
useEffect(() => {
if (!updateRange || !pathname) {
return
}
const { fromV, toV } = updateRange
// TODO: Error handling
runAsync(diffDoc(projectId, fromV, toV, pathname))
}, [projectId, runAsync, pathname, updateRange])
if (isLoading) {
return (

View file

@ -1,5 +1,73 @@
function Toolbar() {
return <div>Toolbar</div>
import { Trans, useTranslation } from 'react-i18next'
import { formatTime } from '../../../utils/format-date'
import type { Nullable } from '../../../../../../types/utils'
import type { Diff } from '../../services/types/doc'
import type { HistoryContextValue } from '../../context/types/history-context-value'
type ToolbarProps = {
diff: Nullable<Diff>
selection: HistoryContextValue['selection']
}
function Toolbar({ diff, selection }: ToolbarProps) {
const { t } = useTranslation()
if (!selection) return null
return (
<div className="history-react-toolbar">
<div>
{selection.comparing ? (
<Trans
i18nKey="comparing_x_to_y"
// eslint-disable-next-line react/jsx-key
components={[<time className="history-react-toolbar-time" />]}
values={{
startTime: formatTime(
selection.updateRange?.fromVTimestamp,
'Do MMMM · h:mm a'
),
endTime: formatTime(
selection.updateRange?.toVTimestamp,
'Do MMMM · h:mm a'
),
}}
/>
) : (
<Trans
i18nKey="viewing_x"
// eslint-disable-next-line react/jsx-key
components={[<time className="history-react-toolbar-time" />]}
values={{
endTime: formatTime(
selection.updateRange?.toVTimestamp,
'Do MMMM · h:mm a'
),
}}
/>
)}
</div>
{selection.pathname ? (
<div className="history-react-toolbar-changes">
{t('x_changes_in', {
count: diff?.docDiff?.highlights.length ?? 0,
})}
&nbsp;
<strong>{getFileName(selection)}</strong>
</div>
) : null}
</div>
)
}
function getFileName(selection: HistoryContextValue['selection']) {
const filePathParts = selection?.pathname?.split('/')
let fileName
if (filePathParts) {
fileName = filePathParts[filePathParts.length - 1]
}
return fileName
}
export default Toolbar

View file

@ -53,7 +53,6 @@ function useHistory() {
// eslint-disable-next-line no-unused-vars
const [userHasFullFeature, setUserHasFullFeature] =
useState<HistoryContextValue['userHasFullFeature']>(undefined)
/* eslint-enable no-unused-vars */
const fetchNextBatchOfUpdates = useCallback(() => {
const loadUpdates = (updatesData: Update[]) => {
@ -173,7 +172,12 @@ function useHistory() {
const { fromV, toV } = updateRange
diffFiles(projectId, fromV, toV).then(({ diff: files }) => {
const pathname = autoSelectFile(files, updateRange, comparing, updates)
const pathname = autoSelectFile(
files,
updateRange.toV,
comparing,
updates
)
const newFiles = files.map(file => {
if (isFileRenamed(file) && file.newPathname) {
return renamePathnameKey(file)
@ -189,7 +193,12 @@ function useHistory() {
// Set update selection if there isn't one
if (updates.length && !updateRange) {
setSelection({
updateRange: updates[0],
updateRange: {
fromV: updates[0].fromV,
toV: updates[0].toV,
fromVTimestamp: updates[0].meta.end_ts,
toVTimestamp: updates[0].meta.end_ts,
},
comparing: false,
files: [],
pathname: null,

View file

@ -24,3 +24,11 @@ export interface Highlight {
range: Range
type: 'addition' | 'deletion'
}
export type Diff = {
binary: boolean
docDiff?: {
doc: string
highlights: Highlight[]
}
}

View file

@ -2,19 +2,25 @@ import { Meta, User } from './shared'
import { Label } from './label'
import { Nullable } from '../../../../../../types/utils'
export type Version = number
export interface ProjectOp {
add?: { pathname: string }
rename?: { pathname: string; newPathname: string }
remove?: { pathname: string }
atV: number
atV: Version
}
export interface UpdateRange {
fromV: number
toV: number
fromV: Version
toV: Version
fromVTimestamp: number
toVTimestamp: number
}
export interface Update extends UpdateRange {
export interface Update {
fromV: Version
toV: Version
meta: Meta
labels: Label[]
pathnames: string[]

View file

@ -3,10 +3,10 @@ import type { Nullable } from '../../../../../types/utils'
import type { HistoryContextValue } from '../context/types/history-context-value'
import type { FileDiff } from '../services/types/file'
import type { DiffOperation } from '../services/types/diff-operation'
import type { LoadedUpdate, UpdateRange } from '../services/types/update'
import type { LoadedUpdate, Version } from '../services/types/update'
function getUpdateForVersion(
version: LoadedUpdate['toV'],
version: Version,
updates: HistoryContextValue['updates']
): Nullable<LoadedUpdate> {
return updates.filter(update => update.toV === version)?.[0] ?? null
@ -19,13 +19,13 @@ type FileWithOps = {
function getFilesWithOps(
files: FileDiff[],
updateRange: UpdateRange,
toV: Version,
comparing: boolean,
updates: HistoryContextValue['updates']
): FileWithOps[] {
if (updateRange.toV && !comparing) {
if (toV && !comparing) {
const filesWithOps: FileWithOps[] = []
const currentUpdate = getUpdateForVersion(updateRange.toV, updates)
const currentUpdate = getUpdateForVersion(toV, updates)
if (currentUpdate !== null) {
for (const pathname of currentUpdate.pathnames) {
@ -90,13 +90,13 @@ const orderedOpTypes: DiffOperation[] = [
export function autoSelectFile(
files: FileDiff[],
updateRange: UpdateRange,
toV: Version,
comparing: boolean,
updates: HistoryContextValue['updates']
) {
let fileToSelect: Nullable<FileDiff> = null
const filesWithOps = getFilesWithOps(files, updateRange, comparing, updates)
const filesWithOps = getFilesWithOps(files, toV, comparing, updates)
for (const opType of orderedOpTypes) {
const fileWithMatchingOpType = _.find(filesWithOps, {
operation: opType,

View file

@ -1,7 +1,7 @@
import ColorManager from '../../../ide/colors/ColorManager'
import { Nullable } from '../../../../../types/utils'
import { User } from '../services/types/shared'
import { ProjectOp, UpdateRange } from '../services/types/update'
import { ProjectOp, Version } from '../services/types/update'
import { Selection } from '../services/types/selection'
export const getUserColor = (user?: Nullable<{ id: string }>) => {
@ -37,10 +37,20 @@ export const getProjectOpDoc = (projectOp: ProjectOp) => {
return ''
}
export const updateIsSelected = (update: UpdateRange, selection: Selection) => {
type UpdateIsSelectedArg = {
fromV: Version
toV: Version
selection: Selection
}
export const updateIsSelected = ({
fromV,
toV,
selection,
}: UpdateIsSelectedArg) => {
return (
selection.updateRange &&
update.fromV >= selection.updateRange.fromV &&
update.toV <= selection.updateRange.toV
fromV >= selection.updateRange.fromV &&
toV <= selection.updateRange.toV
)
}

View file

@ -184,6 +184,17 @@ history-root {
padding: 2px 4px 3px;
margin-top: 2px;
}
.history-react-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
.history-react-toolbar-time {
font-weight: 700;
}
}
}
.history-version-label-tooltip {

View file

@ -242,6 +242,7 @@
"company_name": "Company Name",
"compare_plan_features": "Compare Plan Features",
"compare_to_another_version": "Compare to another version",
"comparing_x_to_y": "Comparing <0>__startTime__</0> to <0>__endTime__</0>",
"compile_error_entry_description": "An error which prevented this project from compiling",
"compile_error_handling": "Compile Error Handling",
"compile_larger_projects": "Compile larger projects",
@ -1669,6 +1670,7 @@
"view_templates": "View templates",
"view_which_changes": "View which changes have been",
"view_your_invoices": "View Your Invoices",
"viewing_x": "Viewing <0>__endTime__</0>",
"want_change_to_apply_before_plan_end": "If you wish this change to apply before the end of your current billing period, please contact us.",
"we_cant_find_any_sections_or_subsections_in_this_file": "We cant find any sections or subsections in this file",
"we_logged_you_in": "We have logged you in.",
@ -1691,6 +1693,8 @@
"work_with_word_users": "Work with Word users",
"work_with_word_users_blurb": "__appName__ is so easy to get started with that youll be able to invite your non-LaTeX colleagues to contribute directly to your LaTeX documents. Theyll be productive from day one and be able to pick up small amounts of LaTeX as they go.",
"would_you_like_to_see_a_university_subscription": "Would you like to see a university-wide __appName__ subscription at your university?",
"x_changes_in": "__count__ change in",
"x_changes_in_plural": "__count__ changes in",
"x_collaborators_per_project": "__collaboratorsCount__ collaborators per project",
"x_price_for_first_month": "<0>__price__</0> for your first month",
"x_price_for_first_year": "<0>__price__</0> for your first year",

View file

@ -0,0 +1,99 @@
import Toolbar from '../../../../../frontend/js/features/history/components/diff-view/toolbar'
import { HistoryContextValue } from '../../../../../frontend/js/features/history/context/types/history-context-value'
import { Diff } from '../../../../../frontend/js/features/history/services/types/doc'
describe('history toolbar', function () {
const diff: Diff = {
binary: false,
docDiff: {
highlights: [
{
range: {
from: 0,
to: 3,
},
hue: 1,
type: 'addition',
label: 'label',
},
],
doc: 'doc',
},
}
it('renders viewing mode', function () {
const selection: HistoryContextValue['selection'] = {
updateRange: {
fromV: 3,
toV: 6,
fromVTimestamp: 1681413775958,
toVTimestamp: 1681413775958,
},
comparing: false,
files: [
{
pathname: 'main.tex',
operation: 'edited',
},
{
pathname: 'sample.bib',
},
{
pathname: 'frog.jpg',
},
],
pathname: 'main.tex',
}
cy.mount(
<div className="history-react">
<Toolbar diff={diff} selection={selection} />
</div>
)
cy.get('.history-react-toolbar').within(() => {
cy.get('div:first-child').contains('Viewing 13th April')
})
cy.get('.history-react-toolbar-changes').contains('1 change in main.tex')
})
it('renders comparing mode', function () {
const selection: HistoryContextValue['selection'] = {
updateRange: {
fromV: 0,
toV: 6,
fromVTimestamp: 1681313775958,
toVTimestamp: 1681413775958,
},
comparing: true,
files: [
{
pathname: 'main.tex',
operation: 'added',
},
{
pathname: 'sample.bib',
operation: 'added',
},
{
pathname: 'frog.jpg',
operation: 'added',
},
],
pathname: 'main.tex',
}
cy.mount(
<div className="history-react">
<Toolbar diff={diff} selection={selection} />
</div>
)
cy.get('.history-react-toolbar').within(() => {
cy.get('div:first-child').contains('Comparing 12th April')
cy.get('div:first-child').contains('to 13th April')
})
})
})

View file

@ -259,7 +259,7 @@ describe('autoSelectFile', function () {
},
]
const pathname = autoSelectFile(files, updates[0], comparing, updates)
const pathname = autoSelectFile(files, updates[0].toV, comparing, updates)
expect(pathname).to.equal('newfolder1/newfile10.tex')
})
@ -324,7 +324,7 @@ describe('autoSelectFile', function () {
},
]
const pathname = autoSelectFile(files, updates[0], comparing, updates)
const pathname = autoSelectFile(files, updates[0].toV, comparing, updates)
expect(pathname).to.equal('newfile1.tex')
})
@ -420,7 +420,7 @@ describe('autoSelectFile', function () {
},
]
const pathname = autoSelectFile(files, updates[0], comparing, updates)
const pathname = autoSelectFile(files, updates[0].toV, comparing, updates)
expect(pathname).to.equal('main3.tex')
})
@ -586,7 +586,7 @@ describe('autoSelectFile', function () {
},
]
const pathname = autoSelectFile(files, updates[0], comparing, updates)
const pathname = autoSelectFile(files, updates[0].toV, comparing, updates)
expect(pathname).to.equal('main.tex')
})
@ -689,7 +689,7 @@ describe('autoSelectFile', function () {
},
]
const pathname = autoSelectFile(files, updates[0], comparing, updates)
const pathname = autoSelectFile(files, updates[0].toV, comparing, updates)
expect(pathname).to.equal('certainly_not_main.tex')
})