diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 0071efe7f9..29e7a9eefa 100644 --- a/services/web/frontend/extracted-translations.json +++ b/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": "", diff --git a/services/web/frontend/js/features/history/components/change-list/history-version.tsx b/services/web/frontend/js/features/history/components/change-list/history-version.tsx index 6eda2b7f17..c5e47ba787 100644 --- a/services/web/frontend/js/features/history/components/change-list/history-version.tsx +++ b/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, diff --git a/services/web/frontend/js/features/history/components/diff-view/diff-view.tsx b/services/web/frontend/js/features/history/components/diff-view/diff-view.tsx index c50fa654d1..58b74e2c87 100644 --- a/services/web/frontend/js/features/history/components/diff-view/diff-view.tsx +++ b/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>(null) + const { selection, projectId } = useHistoryContext() + + const { isLoading, runAsync } = useAsync() + + 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 (
- +
-
+
) diff --git a/services/web/frontend/js/features/history/components/diff-view/main.tsx b/services/web/frontend/js/features/history/components/diff-view/main.tsx index 21a80ee304..6ac955a249 100644 --- a/services/web/frontend/js/features/history/components/diff-view/main.tsx +++ b/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 + isLoading: boolean } -function Main() { +function Main({ diff, isLoading }: MainProps) { const { t } = useTranslation() - const { projectId, selection } = useHistoryContext() - const { isLoading, runAsync, data } = useAsync() - 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 ( diff --git a/services/web/frontend/js/features/history/components/diff-view/toolbar.tsx b/services/web/frontend/js/features/history/components/diff-view/toolbar.tsx index 9cf7fb1b87..b3393d2c28 100644 --- a/services/web/frontend/js/features/history/components/diff-view/toolbar.tsx +++ b/services/web/frontend/js/features/history/components/diff-view/toolbar.tsx @@ -1,5 +1,73 @@ -function Toolbar() { - return
Toolbar
+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 + selection: HistoryContextValue['selection'] +} + +function Toolbar({ diff, selection }: ToolbarProps) { + const { t } = useTranslation() + + if (!selection) return null + + return ( +
+
+ {selection.comparing ? ( + ]} + values={{ + startTime: formatTime( + selection.updateRange?.fromVTimestamp, + 'Do MMMM · h:mm a' + ), + endTime: formatTime( + selection.updateRange?.toVTimestamp, + 'Do MMMM · h:mm a' + ), + }} + /> + ) : ( + ]} + values={{ + endTime: formatTime( + selection.updateRange?.toVTimestamp, + 'Do MMMM · h:mm a' + ), + }} + /> + )} +
+ {selection.pathname ? ( +
+ {t('x_changes_in', { + count: diff?.docDiff?.highlights.length ?? 0, + })} +   + {getFileName(selection)} +
+ ) : null} +
+ ) +} + +function getFileName(selection: HistoryContextValue['selection']) { + const filePathParts = selection?.pathname?.split('/') + let fileName + if (filePathParts) { + fileName = filePathParts[filePathParts.length - 1] + } + + return fileName } export default Toolbar diff --git a/services/web/frontend/js/features/history/context/history-context.tsx b/services/web/frontend/js/features/history/context/history-context.tsx index 84cf02755d..0f0719435b 100644 --- a/services/web/frontend/js/features/history/context/history-context.tsx +++ b/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(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, diff --git a/services/web/frontend/js/features/history/services/types/doc.ts b/services/web/frontend/js/features/history/services/types/doc.ts index 7fd3c51bc4..1aa325aa9e 100644 --- a/services/web/frontend/js/features/history/services/types/doc.ts +++ b/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[] + } +} diff --git a/services/web/frontend/js/features/history/services/types/update.ts b/services/web/frontend/js/features/history/services/types/update.ts index 2284d69f8b..fd388a462d 100644 --- a/services/web/frontend/js/features/history/services/types/update.ts +++ b/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[] diff --git a/services/web/frontend/js/features/history/utils/auto-select-file.ts b/services/web/frontend/js/features/history/utils/auto-select-file.ts index 750e7ecc67..15b616ecc5 100644 --- a/services/web/frontend/js/features/history/utils/auto-select-file.ts +++ b/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 { 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 = 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, diff --git a/services/web/frontend/js/features/history/utils/history-details.ts b/services/web/frontend/js/features/history/utils/history-details.ts index 09f1c1fd25..7c82e0cb08 100644 --- a/services/web/frontend/js/features/history/utils/history-details.ts +++ b/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 ) } diff --git a/services/web/frontend/stylesheets/app/editor/history-react.less b/services/web/frontend/stylesheets/app/editor/history-react.less index 1f8054411c..7340960e36 100644 --- a/services/web/frontend/stylesheets/app/editor/history-react.less +++ b/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 { diff --git a/services/web/locales/en.json b/services/web/locales/en.json index f5f5f5e638..f50e8e5364 100644 --- a/services/web/locales/en.json +++ b/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__ to <0>__endTime__", "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__", "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__ for your first month", "x_price_for_first_year": "<0>__price__ for your first year", diff --git a/services/web/test/frontend/features/history/components/toolbar.spec.tsx b/services/web/test/frontend/features/history/components/toolbar.spec.tsx new file mode 100644 index 0000000000..c78180262a --- /dev/null +++ b/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( +
+ +
+ ) + + 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( +
+ +
+ ) + + cy.get('.history-react-toolbar').within(() => { + cy.get('div:first-child').contains('Comparing 12th April') + + cy.get('div:first-child').contains('to 13th April') + }) + }) +}) diff --git a/services/web/test/frontend/features/history/utils/auto-select-file.test.ts b/services/web/test/frontend/features/history/utils/auto-select-file.test.ts index 4930c78d2f..385a7a6ea5 100644 --- a/services/web/test/frontend/features/history/utils/auto-select-file.test.ts +++ b/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') })