Răsfoiți Sursa

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
M Fahru 3 ani în urmă
părinte
comite
4dec157e08

+ 4 - 0
services/web/frontend/extracted-translations.json

@@ -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": "",

+ 17 - 3
services/web/frontend/js/features/history/components/change-list/history-version.tsx

@@ -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,

+ 44 - 2
services/web/frontend/js/features/history/components/diff-view/diff-view.tsx

@@ -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>
   )

+ 6 - 36
services/web/frontend/js/features/history/components/diff-view/main.tsx

@@ -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 (

+ 70 - 2
services/web/frontend/js/features/history/components/diff-view/toolbar.tsx

@@ -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

+ 12 - 3
services/web/frontend/js/features/history/context/history-context.tsx

@@ -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,

+ 8 - 0
services/web/frontend/js/features/history/services/types/doc.ts

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

+ 10 - 4
services/web/frontend/js/features/history/services/types/update.ts

@@ -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[]

+ 7 - 7
services/web/frontend/js/features/history/utils/auto-select-file.ts

@@ -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,

+ 14 - 4
services/web/frontend/js/features/history/utils/history-details.ts

@@ -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
   )
 }

+ 11 - 0
services/web/frontend/stylesheets/app/editor/history-react.less

@@ -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 {

+ 4 - 0
services/web/locales/en.json

@@ -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 can’t 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 you’ll be able to invite your non-LaTeX colleagues to contribute directly to your LaTeX documents. They’ll 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",

+ 99 - 0
services/web/test/frontend/features/history/components/toolbar.spec.tsx

@@ -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')
+    })
+  })
+})

+ 5 - 5
services/web/test/frontend/features/history/utils/auto-select-file.test.ts

@@ -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')
     })