first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,129 @@
import { useTranslation } from 'react-i18next'
import { useEffect, useState } from 'react'
import OLForm from '@/features/ui/components/ol/ol-form'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import ModalError from './modal-error'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
import useAsync from '../../../../shared/hooks/use-async'
import useAbortController from '../../../../shared/hooks/use-abort-controller'
import useAddOrRemoveLabels from '../../hooks/use-add-or-remove-labels'
import { useHistoryContext } from '../../context/history-context'
import { addLabel } from '../../services/api'
import { Label } from '../../services/types/label'
import { useRefWithAutoFocus } from '../../../../shared/hooks/use-ref-with-auto-focus'
import { debugConsole } from '@/utils/debugging'
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
type AddLabelModalProps = {
show: boolean
setShow: React.Dispatch<React.SetStateAction<boolean>>
version: number
}
function AddLabelModal({ show, setShow, version }: AddLabelModalProps) {
const { t } = useTranslation()
const [comment, setComment] = useState('')
const {
isLoading,
isSuccess,
isError,
error,
data: label,
reset,
runAsync,
} = useAsync<Label>()
const { projectId } = useHistoryContext()
const { signal } = useAbortController()
const { addUpdateLabel } = useAddOrRemoveLabels()
const { autoFocusedRef, resetAutoFocus } =
useRefWithAutoFocus<HTMLInputElement>()
// Reset the autofocus when `show` changes so that autofocus still happens if
// the dialog is shown, hidden and then shown again
useEffect(() => {
if (show) {
resetAutoFocus()
}
}, [resetAutoFocus, show])
const handleModalExited = () => {
setComment('')
if (!isSuccess || !label) return
addUpdateLabel(label)
reset()
}
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
runAsync(addLabel(projectId, { comment, version }, signal))
.then(() => setShow(false))
.catch(debugConsole.error)
}
const responseError = error as unknown as {
response: Response
data?: {
message?: string
}
}
return (
<OLModal
show={show}
onExited={handleModalExited}
onHide={() => setShow(false)}
id="add-history-label"
>
<OLModalHeader>
<OLModalTitle>{t('history_add_label')}</OLModalTitle>
</OLModalHeader>
<OLForm onSubmit={handleSubmit}>
<OLModalBody>
{isError && <ModalError error={responseError} />}
<OLFormGroup>
<OLFormControl
ref={autoFocusedRef}
type="text"
placeholder={t('history_new_label_name')}
required
value={comment}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setComment(e.target.value)
}
/>
</OLFormGroup>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
disabled={isLoading}
onClick={() => setShow(false)}
>
{t('cancel')}
</OLButton>
<OLButton
type="submit"
variant="primary"
disabled={isLoading || !comment.length}
isLoading={isLoading}
>
{t('history_add_label')}
</OLButton>
</OLModalFooter>
</OLForm>
</OLModal>
)
}
export default AddLabelModal

View File

@@ -0,0 +1,348 @@
import { useEffect, useRef, useState } from 'react'
import HistoryVersion from './history-version'
import LoadingSpinner from '../../../../shared/components/loading-spinner'
import { OwnerPaywallPrompt } from './owner-paywall-prompt'
import { NonOwnerPaywallPrompt } from './non-owner-paywall-prompt'
import { isVersionSelected } from '../../utils/history-details'
import { useUserContext } from '../../../../shared/context/user-context'
import useDropdownActiveItem from '../../hooks/use-dropdown-active-item'
import { useHistoryContext } from '../../context/history-context'
import { useEditorContext } from '../../../../shared/context/editor-context'
import OLPopover from '@/features/ui/components/ol/ol-popover'
import OLOverlay from '@/features/ui/components/ol/ol-overlay'
import Close from '@/shared/components/close'
import { Trans, useTranslation } from 'react-i18next'
import MaterialIcon from '@/shared/components/material-icon'
import useTutorial from '@/shared/hooks/promotions/use-tutorial'
import { useFeatureFlag } from '@/shared/context/split-test-context'
function AllHistoryList() {
const { id: currentUserId } = useUserContext()
const {
projectId,
updatesInfo,
fetchNextBatchOfUpdates,
currentUserIsOwner,
selection,
setSelection,
} = useHistoryContext()
const {
visibleUpdateCount,
updates,
atEnd,
loadingState: updatesLoadingState,
} = updatesInfo
const scrollerRef = useRef<HTMLDivElement>(null)
const bottomRef = useRef<HTMLDivElement>(null)
const intersectionObserverRef = useRef<IntersectionObserver | null>(null)
const [bottomVisible, setBottomVisible] = useState(false)
const { activeDropdownItem, setActiveDropdownItem, closeDropdownForItem } =
useDropdownActiveItem()
const showPaywall =
updatesLoadingState === 'ready' && updatesInfo.freeHistoryLimitHit
const showOwnerPaywall = showPaywall && currentUserIsOwner
const showNonOwnerPaywall = showPaywall && !currentUserIsOwner
const visibleUpdates =
visibleUpdateCount === null ? updates : updates.slice(0, visibleUpdateCount)
// Create an intersection observer that watches for any part of an element
// positioned at the bottom of the list to be visible
useEffect(() => {
if (updatesLoadingState === '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()
}
}
}
}
}, [updatesLoadingState])
useEffect(() => {
if (!atEnd && updatesLoadingState === 'ready' && bottomVisible) {
fetchNextBatchOfUpdates()
}
}, [atEnd, bottomVisible, fetchNextBatchOfUpdates, updatesLoadingState])
// 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 (updatesLoadingState !== 'ready' && intersectionObserverRef.current) {
setBottomVisible(false)
if (intersectionObserverRef.current) {
intersectionObserverRef.current.disconnect()
intersectionObserverRef.current = null
}
}
}, [updatesLoadingState])
const { inactiveTutorials } = useEditorContext()
const {
showPopup: showHistoryTutorial,
tryShowingPopup: tryShowingHistoryTutorial,
hideUntilReload: hideHistoryTutorialUntilReload,
completeTutorial: completeHistoryTutorial,
} = useTutorial('react-history-buttons-tutorial', {
name: 'react-history-buttons-tutorial',
})
const {
showPopup: showRestorePromo,
tryShowingPopup: tryShowingRestorePromo,
hideUntilReload: hideRestorePromoUntilReload,
completeTutorial: completeRestorePromo,
} = useTutorial('history-restore-promo', {
name: 'history-restore-promo',
})
const inFileRestoreSplitTest = useFeatureFlag('revert-file')
const inProjectRestoreSplitTest = useFeatureFlag('revert-project')
const hasVisibleUpdates = visibleUpdates.length > 0
const isMoreThanOneVersion = visibleUpdates.length > 1
const [layoutSettled, setLayoutSettled] = useState(false)
// When there is a paywall and only two version's to compare,
// they are not comparable because the one that has a paywall will not have the compare button
// so we should not display on-boarding popover in that case
const isPaywallAndNonComparable =
visibleUpdates.length === 2 && updatesInfo.freeHistoryLimitHit
useEffect(() => {
const hasCompletedHistoryTutorial = inactiveTutorials.includes(
'react-history-buttons-tutorial'
)
const hasCompletedRestorePromotion = inactiveTutorials.includes(
'history-restore-promo'
)
// wait for the layout to settle before showing popover, to avoid a flash/ instant move
if (!layoutSettled) {
return
}
if (
!hasCompletedHistoryTutorial &&
isMoreThanOneVersion &&
!isPaywallAndNonComparable
) {
tryShowingHistoryTutorial()
} else if (
!hasCompletedRestorePromotion &&
inFileRestoreSplitTest &&
inProjectRestoreSplitTest &&
hasVisibleUpdates
) {
tryShowingRestorePromo()
}
}, [
hasVisibleUpdates,
inFileRestoreSplitTest,
inProjectRestoreSplitTest,
tryShowingRestorePromo,
inactiveTutorials,
isMoreThanOneVersion,
isPaywallAndNonComparable,
layoutSettled,
tryShowingHistoryTutorial,
])
const { t } = useTranslation()
let popover = null
// hiding is different from dismissing, as we wont save a full dismissal to the user
// meaning the tutorial will show on page reload/ re-navigation
const hidePopover = () => {
hideHistoryTutorialUntilReload()
hideRestorePromoUntilReload()
}
if (showHistoryTutorial) {
popover = (
<OLOverlay
placement="left-start"
show={showHistoryTutorial}
rootClose
onHide={hidePopover}
// using scrollerRef to position the popover in the middle of the viewport
target={scrollerRef.current}
popperConfig={{
modifiers: [
{
name: 'offset',
options: {
offset: [10, 10],
},
},
],
}}
>
<OLPopover
id="popover-react-history-tutorial"
title={
<span>
{t('react_history_tutorial_title')}{' '}
<Close
variant="dark"
onDismiss={() =>
completeHistoryTutorial({
event: 'promo-click',
action: 'complete',
})
}
/>
</span>
}
className="dark-themed history-popover"
>
<Trans
i18nKey="react_history_tutorial_content"
components={[
// eslint-disable-next-line react/jsx-key
<MaterialIcon
type="align_end"
className="history-dropdown-icon-inverted"
/>,
<a href="https://www.overleaf.com/learn/latex/Using_the_History_feature" />, // eslint-disable-line jsx-a11y/anchor-has-content, react/jsx-key
]}
/>
</OLPopover>
</OLOverlay>
)
} else if (showRestorePromo) {
popover = (
<OLOverlay
placement="left-start"
show={showRestorePromo}
rootClose
onHide={hidePopover}
// using scrollerRef to position the popover in the middle of the viewport
target={scrollerRef.current}
>
<OLPopover
id="popover-history-restore-promo"
title={
<span>
{t('history_restore_promo_title')}
<Close
variant="dark"
onDismiss={() =>
completeRestorePromo({
event: 'promo-click',
action: 'complete',
})
}
/>
</span>
}
className="dark-themed history-popover"
>
<Trans
i18nKey="history_restore_promo_content"
components={[
// eslint-disable-next-line react/jsx-key
<MaterialIcon
type="more_vert"
className="history-restore-promo-icon"
/>,
]}
/>
</OLPopover>
</OLOverlay>
)
}
// give the components time to position before showing popover so we don't get an instant position change
useEffect(() => {
const timer = window.setTimeout(() => {
setLayoutSettled(true)
}, 500)
return () => clearTimeout(timer)
}, [setLayoutSettled])
// resizes can cause the popover to point to the wrong thing, since it changes the horizontal layout of the page
useEffect(() => {
window.addEventListener('resize', hidePopover)
return () => window.removeEventListener('resize', hidePopover)
})
return (
<div ref={scrollerRef} className="history-all-versions-scroller">
{popover}
<div className="history-all-versions-container">
<div ref={bottomRef} className="history-versions-bottom" />
{visibleUpdates.map((update, index) => {
const selectionState = isVersionSelected(
selection,
update.fromV,
update.toV
)
const dropdownActive =
update.toV === activeDropdownItem.item &&
activeDropdownItem.whichDropDown === 'moreOptions'
const compareDropdownActive =
update === activeDropdownItem.item &&
activeDropdownItem.whichDropDown === 'compare'
const showDivider = Boolean(update.meta.first_in_day && index > 0)
const faded =
updatesInfo.freeHistoryLimitHit &&
index === visibleUpdates.length - 1 &&
visibleUpdates.length > 1
const selectable =
!faded &&
(selection.comparing ||
selectionState === 'aboveSelected' ||
selectionState === 'belowSelected')
return (
<HistoryVersion
key={`${update.fromV}_${update.toV}`}
update={update}
faded={faded}
showDivider={showDivider}
setSelection={setSelection}
selectionState={selectionState}
currentUserId={currentUserId!}
selectable={selectable}
projectId={projectId}
setActiveDropdownItem={setActiveDropdownItem}
closeDropdownForItem={closeDropdownForItem}
dropdownOpen={activeDropdownItem.isOpened && dropdownActive}
compareDropdownActive={compareDropdownActive}
compareDropdownOpen={
activeDropdownItem.isOpened && compareDropdownActive
}
dropdownActive={dropdownActive}
/>
)
})}
</div>
{showOwnerPaywall ? <OwnerPaywallPrompt /> : null}
{showNonOwnerPaywall ? <NonOwnerPaywallPrompt /> : null}
{updatesLoadingState === 'loadingInitial' ||
updatesLoadingState === 'loadingUpdates' ? (
<div className="history-all-versions-loading">
<LoadingSpinner />
</div>
) : null}
</div>
)
}
export default AllHistoryList

View File

@@ -0,0 +1,21 @@
import ToggleSwitch from './toggle-switch'
import AllHistoryList from './all-history-list'
import LabelsList from './labels-list'
import { useHistoryContext } from '../../context/history-context'
function ChangeList() {
const { labelsOnly, setLabelsOnly } = useHistoryContext()
return (
<aside className="change-list">
<div className="history-header history-toggle-switch-container">
<ToggleSwitch labelsOnly={labelsOnly} setLabelsOnly={setLabelsOnly} />
</div>
<div className="history-version-list-container">
{labelsOnly ? <LabelsList /> : <AllHistoryList />}
</div>
</aside>
)
}
export default ChangeList

View File

@@ -0,0 +1,53 @@
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"
data-testid="history-version-change-action"
>
{t('file_action_edited')}
</div>
<div
className="history-version-change-doc"
data-testid="history-version-change-doc"
>
{pathname}
</div>
</li>
))}
{projectOps.map((op, index) => (
<li key={index}>
<div
className="history-version-change-action"
data-testid="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"
data-testid="history-version-change-doc"
>
{getProjectOpDoc(op)}
</div>
</li>
))}
</ol>
)
}
export default Changes

View File

@@ -0,0 +1,44 @@
import React, { ReactNode } from 'react'
import {
Dropdown,
DropdownMenu,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
import DropdownToggleWithTooltip from '@/features/ui/components/bootstrap-5/dropdown-toggle-with-tooltip'
type ActionDropdownProps = {
id: string
children: React.ReactNode
isOpened: boolean
iconTag: ReactNode
toolTipDescription: string
setIsOpened: (isOpened: boolean) => void
}
function ActionsDropdown(props: ActionDropdownProps) {
const { id, children, isOpened, iconTag, setIsOpened, toolTipDescription } =
props
return (
<Dropdown
align="end"
className="float-end"
show={isOpened}
onToggle={open => setIsOpened(open)}
>
<DropdownToggleWithTooltip
id={`history-version-dropdown-${id}`}
className="history-version-dropdown-menu-btn"
aria-label={toolTipDescription}
toolTipDescription={toolTipDescription}
overlayTriggerProps={{ placement: 'bottom' }}
tooltipProps={{ hidden: isOpened }}
>
{iconTag}
</DropdownToggleWithTooltip>
<DropdownMenu className="history-version-dropdown-menu">
{children}
</DropdownMenu>
</Dropdown>
)
}
export default ActionsDropdown

View File

@@ -0,0 +1,120 @@
import { LoadedUpdate, Version } from '../../../services/types/update'
import { useCallback } from 'react'
import { ActiveDropdown } from '../../../hooks/use-dropdown-active-item'
import { useTranslation } from 'react-i18next'
import { updateRangeForUpdate } from '../../../utils/history-details'
import CompareDropDownItem from './menu-item/compare-dropdown-item'
import { useHistoryContext } from '../../../context/history-context'
import MaterialIcon from '../../../../../shared/components/material-icon'
type VersionDropdownContentAllHistoryProps = {
update: LoadedUpdate
closeDropdownForItem: ActiveDropdown['closeDropdownForItem']
}
function CompareVersionDropdownContentAllHistory({
update,
closeDropdownForItem,
}: VersionDropdownContentAllHistoryProps) {
const updateRange = updateRangeForUpdate(update)
const closeDropdown = useCallback(() => {
closeDropdownForItem(update, 'compare')
}, [closeDropdownForItem, update])
const { t } = useTranslation()
const { selection } = useHistoryContext()
const { updateRange: selRange } = selection
if (selRange === null) {
return null
}
return (
<>
<CompareDropDownItem
comparisonRange={{
fromV: selRange.fromV,
toV: updateRange.toV,
fromVTimestamp: selRange.fromVTimestamp,
toVTimestamp: updateRange.toVTimestamp,
}}
closeDropdown={closeDropdown}
text={t('history_compare_up_to_this_version')}
icon={
<MaterialIcon type="align_start" className="history-dropdown-icon" />
}
/>
<CompareDropDownItem
comparisonRange={{
fromV: updateRange.fromV,
toV: selRange.toV,
fromVTimestamp: updateRange.fromVTimestamp,
toVTimestamp: selRange.toVTimestamp,
}}
closeDropdown={closeDropdown}
text={t('history_compare_from_this_version')}
icon={
<MaterialIcon type="align_end" className="history-dropdown-icon" />
}
/>
</>
)
}
type VersionDropdownContentLabelsListProps = {
version: Version
versionTimestamp: number
closeDropdownForItem: ActiveDropdown['closeDropdownForItem']
}
function CompareVersionDropdownContentLabelsList({
version,
versionTimestamp,
closeDropdownForItem,
}: VersionDropdownContentLabelsListProps) {
const closeDropdownLabels = useCallback(() => {
closeDropdownForItem(version, 'compare')
}, [closeDropdownForItem, version])
const { t } = useTranslation()
const { selection } = useHistoryContext()
const { updateRange: selRange } = selection
if (selRange === null) {
return null
}
return (
<>
<CompareDropDownItem
comparisonRange={{
fromV: selRange.fromV,
toV: version,
fromVTimestamp: selRange.fromVTimestamp,
toVTimestamp: versionTimestamp,
}}
closeDropdown={closeDropdownLabels}
text={t('history_compare_up_to_this_version')}
icon={
<MaterialIcon type="align_start" className="history-dropdown-icon" />
}
/>
<CompareDropDownItem
comparisonRange={{
fromV: version,
toV: selRange.toV,
fromVTimestamp: versionTimestamp,
toVTimestamp: selRange.toVTimestamp,
}}
closeDropdown={closeDropdownLabels}
text={t('history_compare_from_this_version')}
icon={
<MaterialIcon type="align_end" className="history-dropdown-icon" />
}
/>
</>
)
}
export {
CompareVersionDropdownContentAllHistory,
CompareVersionDropdownContentLabelsList,
}

View File

@@ -0,0 +1,38 @@
import ActionsDropdown from './actions-dropdown'
import MaterialIcon from '../../../../../shared/components/material-icon'
import { useTranslation } from 'react-i18next'
type CompareVersionDropdownProps = {
children: React.ReactNode
id: string
isOpened: boolean
setIsOpened: (isOpened: boolean) => void
}
function CompareVersionDropdown({
children,
id,
isOpened,
setIsOpened,
}: CompareVersionDropdownProps) {
const { t } = useTranslation()
return (
<ActionsDropdown
id={id}
isOpened={isOpened}
setIsOpened={setIsOpened}
toolTipDescription={t('compare')}
iconTag={
<MaterialIcon
type="align_space_even"
className="history-dropdown-icon"
accessibilityLabel={t('compare')}
/>
}
>
{children}
</ActionsDropdown>
)
}
export default CompareVersionDropdown

View File

@@ -0,0 +1,54 @@
import AddLabel from './menu-item/add-label'
import Download from './menu-item/download'
import { Version } from '../../../services/types/update'
import { useCallback } from 'react'
import { ActiveDropdown } from '../../../hooks/use-dropdown-active-item'
import RestoreProject from './menu-item/restore-project'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
type VersionDropdownContentProps = {
projectId: string
version: Version
closeDropdownForItem: ActiveDropdown['closeDropdownForItem']
endTimestamp: number
}
function HistoryDropdownContent({
projectId,
version,
closeDropdownForItem,
endTimestamp,
}: VersionDropdownContentProps) {
const closeDropdown = useCallback(() => {
closeDropdownForItem(version, 'moreOptions')
}, [closeDropdownForItem, version])
const permissions = usePermissionsContext()
return (
<>
{permissions.labelVersion && (
<AddLabel
projectId={projectId}
version={version}
closeDropdown={closeDropdown}
/>
)}
<Download
projectId={projectId}
version={version}
closeDropdown={closeDropdown}
/>
{permissions.write && (
<RestoreProject
projectId={projectId}
version={version}
closeDropdown={closeDropdown}
endTimestamp={endTimestamp}
/>
)}
</>
)
}
export default HistoryDropdownContent

View File

@@ -0,0 +1,34 @@
import ActionsDropdown from './actions-dropdown'
import { useTranslation } from 'react-i18next'
import MaterialIcon from '@/shared/components/material-icon'
type HistoryDropdownProps = {
children: React.ReactNode
id: string
isOpened: boolean
setIsOpened: (isOpened: boolean) => void
}
function HistoryDropdown({
children,
id,
isOpened,
setIsOpened,
}: HistoryDropdownProps) {
const { t } = useTranslation()
return (
<ActionsDropdown
id={id}
isOpened={isOpened}
toolTipDescription={t('more_actions')}
setIsOpened={setIsOpened}
iconTag={
<MaterialIcon type="more_vert" accessibilityLabel={t('more_actions')} />
}
>
{children}
</ActionsDropdown>
)
}
export default HistoryDropdown

View File

@@ -0,0 +1,47 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
import OLTagIcon from '@/features/ui/components/ol/icons/ol-tag-icon'
import AddLabelModal from '../../add-label-modal'
type DownloadProps = {
projectId: string
version: number
closeDropdown: () => void
}
function AddLabel({
version,
projectId,
closeDropdown,
...props
}: DownloadProps) {
const { t } = useTranslation()
const [showModal, setShowModal] = useState(false)
const handleClick = () => {
closeDropdown()
setShowModal(true)
}
return (
<>
<OLDropdownMenuItem
onClick={handleClick}
leadingIcon={<OLTagIcon />}
as="button"
className="dropdown-item-material-icon-small"
{...props}
>
{t('history_label_this_version')}
</OLDropdownMenuItem>
<AddLabelModal
show={showModal}
setShow={setShowModal}
version={version}
/>
</>
)
}
export default AddLabel

View File

@@ -0,0 +1,47 @@
import { useHistoryContext } from '../../../../context/history-context'
import { UpdateRange } from '../../../../services/types/update'
import { ReactNode } from 'react'
import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
type CompareProps = {
comparisonRange: UpdateRange
icon: ReactNode
text: string
closeDropdown: () => void
}
function CompareDropDownItem({
comparisonRange,
text,
closeDropdown,
icon,
...props
}: CompareProps) {
const { setSelection } = useHistoryContext()
const handleCompareVersion = (e: React.MouseEvent) => {
e.stopPropagation()
closeDropdown()
setSelection(({ previouslySelectedPathname }) => ({
updateRange: comparisonRange,
comparing: true,
files: [],
previouslySelectedPathname,
}))
}
return (
<OLDropdownMenuItem
{...props}
leadingIcon={icon}
as="button"
onClick={handleCompareVersion}
className="dropdown-item-material-icon-small"
>
{text}
</OLDropdownMenuItem>
)
}
export default CompareDropDownItem

View File

@@ -0,0 +1,68 @@
import { useTranslation } from 'react-i18next'
import { useHistoryContext } from '../../../../context/history-context'
import { UpdateRange } from '../../../../services/types/update'
import Compare from './compare'
import MaterialIcon from '../../../../../../shared/components/material-icon'
import { ItemSelectionState } from '../../../../utils/history-details'
type CompareItemsProps = {
updateRange: UpdateRange
selectionState: ItemSelectionState
closeDropdown: () => void
}
function CompareItems({
updateRange,
selectionState,
closeDropdown,
}: CompareItemsProps) {
const { t } = useTranslation()
const { selection } = useHistoryContext()
const { updateRange: selRange } = selection
if (selRange === null) {
return null
}
return (
<>
{selectionState === 'belowSelected' ? (
<Compare
comparisonRange={{
fromV: updateRange.fromV,
toV: selRange.toV,
fromVTimestamp: updateRange.fromVTimestamp,
toVTimestamp: selRange.toVTimestamp,
}}
closeDropdown={closeDropdown}
toolTipDescription={t('history_compare_from_this_version')}
icon={
<MaterialIcon
type="align_end"
className="history-dropdown-icon pb-1"
/>
}
/>
) : null}
{selectionState === 'aboveSelected' ? (
<Compare
comparisonRange={{
fromV: selRange.fromV,
toV: updateRange.toV,
fromVTimestamp: selRange.fromVTimestamp,
toVTimestamp: updateRange.toVTimestamp,
}}
closeDropdown={closeDropdown}
toolTipDescription={t('history_compare_up_to_this_version')}
icon={
<MaterialIcon
type="align_start"
className="history-dropdown-icon pt-1"
/>
}
/>
) : null}
</>
)
}
export default CompareItems

View File

@@ -0,0 +1,47 @@
import { useHistoryContext } from '../../../../context/history-context'
import { UpdateRange } from '../../../../services/types/update'
import { ReactNode } from 'react'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
type CompareProps = {
comparisonRange: UpdateRange
icon: ReactNode
toolTipDescription?: string
closeDropdown: () => void
}
function Compare({
comparisonRange,
closeDropdown,
toolTipDescription,
icon,
}: CompareProps) {
const { setSelection } = useHistoryContext()
const handleCompareVersion = (e: { stopPropagation: () => void }) => {
e.stopPropagation()
closeDropdown()
setSelection(({ previouslySelectedPathname }) => ({
updateRange: comparisonRange,
comparing: true,
files: [],
previouslySelectedPathname,
}))
}
return (
<OLTooltip
description={toolTipDescription}
id="compare-btn"
overlayProps={{ placement: 'left' }}
>
<button className="history-compare-btn" onClick={handleCompareVersion}>
<span className="visually-hidden">{toolTipDescription}</span>
{icon}
</button>
</OLTooltip>
)
}
export default Compare

View File

@@ -0,0 +1,33 @@
import { useTranslation } from 'react-i18next'
import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
import MaterialIcon from '@/shared/components/material-icon'
type DownloadProps = {
projectId: string
version: number
closeDropdown: () => void
}
function Download({
version,
projectId,
closeDropdown,
...props
}: DownloadProps) {
const { t } = useTranslation()
return (
<OLDropdownMenuItem
href={`/project/${projectId}/version/${version}/zip`}
download={`${projectId}_v${version}.zip`}
rel="noreferrer"
onClick={closeDropdown}
leadingIcon={<MaterialIcon type="download" />}
{...props}
>
{t('history_download_this_version')}
</OLDropdownMenuItem>
)
}
export default Download

View File

@@ -0,0 +1,65 @@
import { useCallback, useState } from 'react'
import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
import { useTranslation } from 'react-i18next'
import { RestoreProjectModal } from '../../../diff-view/modals/restore-project-modal'
import { useSplitTestContext } from '@/shared/context/split-test-context'
import { useRestoreProject } from '@/features/history/context/hooks/use-restore-project'
import withErrorBoundary from '@/infrastructure/error-boundary'
import { RestoreProjectErrorModal } from '../../../diff-view/modals/restore-project-error-modal'
import MaterialIcon from '@/shared/components/material-icon'
type RestoreProjectProps = {
projectId: string
version: number
closeDropdown: () => void
endTimestamp: number
}
const RestoreProject = ({
projectId,
version,
closeDropdown,
endTimestamp,
}: RestoreProjectProps) => {
const { t } = useTranslation()
const [showModal, setShowModal] = useState(false)
const { splitTestVariants } = useSplitTestContext()
const { restoreProject, isRestoring } = useRestoreProject()
const handleClick = useCallback(() => {
closeDropdown()
setShowModal(true)
}, [closeDropdown])
const onRestore = useCallback(() => {
restoreProject(projectId, version)
}, [restoreProject, version, projectId])
if (
splitTestVariants['revert-file'] !== 'enabled' ||
splitTestVariants['revert-project'] !== 'enabled'
) {
return null
}
return (
<>
<OLDropdownMenuItem
as="button"
leadingIcon={<MaterialIcon type="undo" />}
onClick={handleClick}
>
{t('restore_project_to_this_version')}
</OLDropdownMenuItem>
<RestoreProjectModal
setShow={setShowModal}
show={showModal}
endTimestamp={endTimestamp}
isRestoring={isRestoring}
onRestore={onRestore}
/>
</>
)
}
export default withErrorBoundary(RestoreProject, RestoreProjectErrorModal)

View File

@@ -0,0 +1,22 @@
import { formatTime } from '@/features/utils/format-date'
import { useTranslation } from 'react-i18next'
import { LoadedUpdate } from '../../services/types/update'
function FileRestoreChange({ origin }: Pick<LoadedUpdate['meta'], 'origin'>) {
const { t } = useTranslation()
if (!origin || origin.kind !== 'file-restore') {
return null
}
return (
<div className="history-version-restore-file">
{t('file_action_restored', {
fileName: origin.path,
date: formatTime(origin.timestamp, 'Do MMMM, h:mm a'),
})}
</div>
)
}
export default FileRestoreChange

View File

@@ -0,0 +1,9 @@
import { useTranslation } from 'react-i18next'
function HistoryResyncChange() {
const { t } = useTranslation()
return <div>{t('history_resync')}</div>
}
export default HistoryResyncChange

View File

@@ -0,0 +1,55 @@
import classnames from 'classnames'
import { HistoryContextValue } from '../../context/types/history-context-value'
import { UpdateRange } from '../../services/types/update'
import { ReactNode, MouseEvent } from 'react'
import { ItemSelectionState } from '../../utils/history-details'
type HistoryVersionDetailsProps = {
children: ReactNode
updateRange: UpdateRange
selectionState: ItemSelectionState
selectable: boolean
setSelection: HistoryContextValue['setSelection']
}
function HistoryVersionDetails({
children,
selectionState,
updateRange,
selectable,
setSelection,
}: HistoryVersionDetailsProps) {
const handleSelect = (e: MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement
if (!target.closest('.dropdown') && e.currentTarget.contains(target)) {
setSelection(({ previouslySelectedPathname }) => ({
updateRange,
comparing: false,
files: [],
previouslySelectedPathname,
}))
}
}
return (
// TODO: Sort out accessibility for this
// eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions
<div
className={classnames('history-version-details', {
'history-version-selected':
selectionState === 'upperSelected' ||
selectionState === 'lowerSelected' ||
selectionState === 'selected',
'history-version-within-selected': selectionState === 'withinSelected',
'history-version-selectable': selectable,
})}
data-testid="history-version-details"
data-selected={selectionState}
onClick={selectable ? handleSelect : undefined}
>
{children}
</div>
)
}
export default HistoryVersionDetails

View File

@@ -0,0 +1,204 @@
import { useCallback, memo } from 'react'
import HistoryVersionDetails from './history-version-details'
import TagTooltip from './tag-tooltip'
import Changes from './changes'
import MetadataUsersList from './metadata-users-list'
import Origin from './origin'
import HistoryDropdown from './dropdown/history-dropdown'
import { formatTime, relativeDate } from '../../../utils/format-date'
import { orderBy } from 'lodash'
import { LoadedUpdate } from '../../services/types/update'
import classNames from 'classnames'
import {
updateRangeForUpdate,
ItemSelectionState,
} from '../../utils/history-details'
import { ActiveDropdown } from '../../hooks/use-dropdown-active-item'
import { HistoryContextValue } from '../../context/types/history-context-value'
import HistoryDropdownContent from './dropdown/history-dropdown-content'
import CompareItems from './dropdown/menu-item/compare-items'
import CompareVersionDropdown from './dropdown/compare-version-dropdown'
import { CompareVersionDropdownContentAllHistory } from './dropdown/compare-version-dropdown-content'
import FileRestoreChange from './file-restore-change'
import HistoryResyncChange from './history-resync-change'
import ProjectRestoreChange from './project-restore-change'
type HistoryVersionProps = {
update: LoadedUpdate
currentUserId: string
projectId: string
selectable: boolean
faded: boolean
showDivider: boolean
selectionState: ItemSelectionState
setSelection: HistoryContextValue['setSelection']
dropdownOpen: boolean
dropdownActive: boolean
compareDropdownOpen: boolean
compareDropdownActive: boolean
setActiveDropdownItem: ActiveDropdown['setActiveDropdownItem']
closeDropdownForItem: ActiveDropdown['closeDropdownForItem']
}
function HistoryVersion({
update,
currentUserId,
projectId,
selectable,
faded,
showDivider,
selectionState,
setSelection,
dropdownOpen,
dropdownActive,
compareDropdownOpen,
compareDropdownActive,
setActiveDropdownItem,
closeDropdownForItem,
}: HistoryVersionProps) {
const orderedLabels = orderBy(update.labels, ['created_at'], ['desc'])
const closeDropdown = useCallback(() => {
closeDropdownForItem(update.toV, 'moreOptions')
}, [closeDropdownForItem, update])
const updateRange = updateRangeForUpdate(update)
return (
<>
{showDivider ? (
<div
className={classNames({
'history-version-divider-container': true,
'version-element-within-selected ':
selectionState === 'withinSelected' ||
selectionState === 'lowerSelected',
})}
>
<hr className="history-version-divider" />
</div>
) : null}
{update.meta.first_in_day ? (
<div
className={classNames('history-version-day', {
'version-element-within-selected':
selectionState === 'withinSelected' ||
selectionState === 'lowerSelected',
})}
>
<time>{relativeDate(update.meta.end_ts)}</time>
</div>
) : null}
<div
data-testid="history-version"
className={classNames({
'history-version-faded': faded,
})}
>
<HistoryVersionDetails
selectionState={selectionState}
setSelection={setSelection}
updateRange={updateRangeForUpdate(update)}
selectable={selectable}
>
{faded ? null : (
<HistoryDropdown
id={`${update.fromV}_${update.toV}`}
isOpened={dropdownOpen}
setIsOpened={(isOpened: boolean) =>
setActiveDropdownItem({
item: update.toV,
isOpened,
whichDropDown: 'moreOptions',
})
}
>
{dropdownActive ? (
<HistoryDropdownContent
version={update.toV}
endTimestamp={update.meta.end_ts}
projectId={projectId}
closeDropdownForItem={closeDropdownForItem}
/>
) : null}
</HistoryDropdown>
)}
{selectionState !== 'selected' && !faded ? (
<div data-testid="compare-icon-version" className="float-end">
{selectionState !== 'withinSelected' ? (
<CompareItems
updateRange={updateRange}
selectionState={selectionState}
closeDropdown={closeDropdown}
/>
) : (
<CompareVersionDropdown
id={`${update.fromV}_${update.toV}`}
isOpened={compareDropdownOpen}
setIsOpened={(isOpened: boolean) =>
setActiveDropdownItem({
item: update,
isOpened,
whichDropDown: 'compare',
})
}
>
{compareDropdownActive ? (
<CompareVersionDropdownContentAllHistory
update={update}
closeDropdownForItem={closeDropdownForItem}
/>
) : null}
</CompareVersionDropdown>
)}
</div>
) : null}
<div className="history-version-main-details">
<time
className="history-version-metadata-time"
data-testid="history-version-metadata-time"
>
<b>{formatTime(update.meta.end_ts, 'Do MMMM, h:mm a')}</b>
</time>
{orderedLabels.map(label => (
<TagTooltip
key={label.id}
showTooltip
currentUserId={currentUserId}
label={label}
/>
))}
<ChangeEntry update={update} />
{update.meta.origin?.kind !== 'history-resync' ? (
<>
<MetadataUsersList
users={update.meta.users}
origin={update.meta.origin}
currentUserId={currentUserId}
/>
<Origin origin={update.meta.origin} />
</>
) : null}
</div>
</HistoryVersionDetails>
</div>
</>
)
}
function ChangeEntry({ update }: { update: LoadedUpdate }) {
switch (update.meta.origin?.kind) {
case 'file-restore':
return <FileRestoreChange origin={update.meta.origin} />
case 'history-resync':
return <HistoryResyncChange />
case 'project-restore':
return <ProjectRestoreChange origin={update.meta.origin} />
default:
return (
<Changes pathnames={update.pathnames} projectOps={update.project_ops} />
)
}
}
export default memo(HistoryVersion)

View File

@@ -0,0 +1,154 @@
import { memo, useCallback } from 'react'
import { UpdateRange, Version } from '../../services/types/update'
import TagTooltip from './tag-tooltip'
import { FormatTimeBasedOnYear } from '@/shared/components/format-time-based-on-year'
import HistoryDropdown from './dropdown/history-dropdown'
import HistoryVersionDetails from './history-version-details'
import { LoadedLabel } from '../../services/types/label'
import { useTranslation } from 'react-i18next'
import { ActiveDropdown } from '../../hooks/use-dropdown-active-item'
import { HistoryContextValue } from '../../context/types/history-context-value'
import CompareItems from './dropdown/menu-item/compare-items'
import { ItemSelectionState } from '../../utils/history-details'
import CompareVersionDropdown from './dropdown/compare-version-dropdown'
import { CompareVersionDropdownContentLabelsList } from './dropdown/compare-version-dropdown-content'
import HistoryDropdownContent from '@/features/history/components/change-list/dropdown/history-dropdown-content'
type LabelListItemProps = {
version: Version
labels: LoadedLabel[]
currentUserId: string
projectId: string
selectionState: ItemSelectionState
selectable: boolean
setSelection: HistoryContextValue['setSelection']
dropdownOpen: boolean
dropdownActive: boolean
compareDropdownOpen: boolean
compareDropdownActive: boolean
setActiveDropdownItem: ActiveDropdown['setActiveDropdownItem']
closeDropdownForItem: ActiveDropdown['closeDropdownForItem']
}
function LabelListItem({
version,
labels,
currentUserId,
projectId,
selectionState,
selectable,
setSelection,
dropdownOpen,
dropdownActive,
compareDropdownOpen,
compareDropdownActive,
setActiveDropdownItem,
closeDropdownForItem,
}: LabelListItemProps) {
const { t } = useTranslation()
// first label
const fromVTimestamp = Date.parse(labels[labels.length - 1].created_at)
// most recent label
const toVTimestamp = Date.parse(labels[0].created_at)
const updateRange: UpdateRange = {
fromV: version,
toV: version,
fromVTimestamp,
toVTimestamp,
}
const setIsOpened = useCallback(
(isOpened: boolean) => {
setActiveDropdownItem({
item: version,
isOpened,
whichDropDown: 'moreOptions',
})
},
[setActiveDropdownItem, version]
)
const closeDropdown = useCallback(() => {
closeDropdownForItem(version, 'moreOptions')
}, [closeDropdownForItem, version])
return (
<HistoryVersionDetails
key={version}
updateRange={updateRange}
selectionState={selectionState}
selectable={selectable}
setSelection={setSelection}
>
<HistoryDropdown
id={version.toString()}
isOpened={dropdownOpen}
setIsOpened={setIsOpened}
>
{dropdownActive ? (
<HistoryDropdownContent
version={version}
projectId={projectId}
closeDropdownForItem={closeDropdownForItem}
endTimestamp={toVTimestamp}
/>
) : null}
</HistoryDropdown>
{selectionState !== 'selected' ? (
<div data-testid="compare-icon-version" className="float-end">
{selectionState !== 'withinSelected' ? (
<CompareItems
updateRange={updateRange}
selectionState={selectionState}
closeDropdown={closeDropdown}
/>
) : (
<CompareVersionDropdown
id={version.toString()}
isOpened={compareDropdownOpen}
setIsOpened={(isOpened: boolean) =>
setActiveDropdownItem({
item: version,
isOpened,
whichDropDown: 'compare',
})
}
>
{compareDropdownActive ? (
<CompareVersionDropdownContentLabelsList
version={version}
closeDropdownForItem={closeDropdownForItem}
versionTimestamp={toVTimestamp}
/>
) : null}
</CompareVersionDropdown>
)}
</div>
) : null}
<div className="history-version-main-details">
{labels.map(label => (
<div key={label.id} className="history-version-label">
<TagTooltip
showTooltip
currentUserId={currentUserId}
label={label}
/>
{label.lastUpdatedTimestamp && (
<time
className="history-version-metadata-time"
data-testid="history-version-metadata-time"
>
{t('last_edit')}{' '}
<FormatTimeBasedOnYear date={label.lastUpdatedTimestamp} />
</time>
)}
</div>
))}
</div>
</HistoryVersionDetails>
)
}
export default memo(LabelListItem)

View File

@@ -0,0 +1,56 @@
import { useUserContext } from '../../../../shared/context/user-context'
import { isVersionSelected } from '../../utils/history-details'
import { useMemo } from 'react'
import LabelListItem from './label-list-item'
import useDropdownActiveItem from '../../hooks/use-dropdown-active-item'
import { getVersionWithLabels } from '../../utils/label'
import { useHistoryContext } from '../../context/history-context'
function LabelsList() {
const { id: currentUserId } = useUserContext()
const { projectId, labels, selection, setSelection } = useHistoryContext()
const { activeDropdownItem, setActiveDropdownItem, closeDropdownForItem } =
useDropdownActiveItem()
const versionWithLabels = useMemo(
() => getVersionWithLabels(labels),
[labels]
)
return (
<>
{versionWithLabels.map(({ version, labels }) => {
const selectionState = isVersionSelected(selection, version)
const dropdownActive =
version === activeDropdownItem.item &&
activeDropdownItem.whichDropDown === 'moreOptions'
const compareDropdownActive =
version === activeDropdownItem.item &&
activeDropdownItem.whichDropDown === 'compare'
return (
<LabelListItem
key={version}
labels={labels}
version={version}
currentUserId={currentUserId!}
projectId={projectId}
selectionState={selectionState}
selectable={selectionState !== 'selected'}
setSelection={setSelection}
dropdownOpen={activeDropdownItem.isOpened && dropdownActive}
dropdownActive={dropdownActive}
compareDropdownActive={compareDropdownActive}
compareDropdownOpen={
activeDropdownItem.isOpened && compareDropdownActive
}
setActiveDropdownItem={setActiveDropdownItem}
closeDropdownForItem={closeDropdownForItem}
/>
)
})}
</>
)
}
export default LabelsList

View File

@@ -0,0 +1,43 @@
import { useTranslation } from 'react-i18next'
import { LoadedUpdate } from '../../services/types/update'
import UserNameWithColoredBadge from './user-name-with-colored-badge'
import { getBackgroundColorForUserId } from '@/shared/utils/colors'
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"
data-testid="history-version-metadata-users"
>
{users.map((user, index) => (
<li key={index}>
<UserNameWithColoredBadge user={user} currentUserId={currentUserId} />
</li>
))}
{!users.length && (
<li>
<span
className="history-version-user-badge-color"
style={{ backgroundColor: getBackgroundColorForUserId() }}
/>
{origin?.kind === 'history-resync' ||
origin?.kind === 'history-migration'
? t('overleaf_history_system')
: t('anonymous')}
</li>
)}
</ol>
)
}
export default MetadataUsersList

View File

@@ -0,0 +1,36 @@
import { useTranslation } from 'react-i18next'
import OLNotification from '@/features/ui/components/ol/ol-notification'
// Using this workaround due to inconsistent and improper error responses from the server
type ModalErrorProps = {
error: {
response?: Response
data?: {
message?: string
}
}
}
function ModalError({ error }: ModalErrorProps) {
const { t } = useTranslation()
if (error.response?.status === 400 && error.data?.message) {
return (
<OLNotification
type="error"
content={error.data.message}
className="row-spaced-small"
/>
)
}
return (
<OLNotification
type="error"
content={t('generic_something_went_wrong')}
className="row-spaced-small"
/>
)
}
export default ModalError

View File

@@ -0,0 +1,15 @@
import { useTranslation } from 'react-i18next'
export function NonOwnerPaywallPrompt() {
const { t } = useTranslation()
return (
<div className="history-paywall-prompt">
<h2 className="history-paywall-heading">{t('premium_feature')}</h2>
<p>{t('currently_seeing_only_24_hrs_history')}</p>
<p>
<strong>{t('ask_proj_owner_to_upgrade_for_full_history')}</strong>
</p>
</div>
)
}

View File

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

View File

@@ -0,0 +1,61 @@
import { useTranslation } from 'react-i18next'
import Icon from '../../../../shared/components/icon'
import { useCallback, useEffect, useState } from 'react'
import * as eventTracking from '../../../../infrastructure/event-tracking'
import StartFreeTrialButton from '../../../../shared/components/start-free-trial-button'
function FeatureItem({ text }: { text: string }) {
return (
<li>
<Icon type="check" /> {text}
</li>
)
}
export function OwnerPaywallPrompt() {
const { t } = useTranslation()
const [clickedFreeTrialButton, setClickedFreeTrialButton] = useState(false)
useEffect(() => {
eventTracking.send('subscription-funnel', 'editor-click-feature', 'history')
eventTracking.sendMB('paywall-prompt', { 'paywall-type': 'history' })
}, [])
const handleFreeTrialClick = useCallback(() => {
setClickedFreeTrialButton(true)
}, [])
return (
<div className="history-paywall-prompt">
<h2 className="history-paywall-heading">{t('premium_feature')}</h2>
<p>{t('currently_seeing_only_24_hrs_history')}</p>
<p>
<strong>
{t('upgrade_to_get_feature', { feature: 'full project history' })}
</strong>
</p>
<ul className="history-feature-list">
<FeatureItem text={t('unlimited_projects')} />
<FeatureItem
text={t('collabs_per_proj', { collabcount: 'Multiple' })}
/>
<FeatureItem text={t('full_doc_history')} />
<FeatureItem text={t('sync_to_dropbox')} />
<FeatureItem text={t('sync_to_github')} />
<FeatureItem text={t('compile_larger_projects')} />
</ul>
<p>
<StartFreeTrialButton
source="history"
buttonProps={{ variant: 'premium' }}
handleClick={handleFreeTrialClick}
>
{t('start_free_trial')}
</StartFreeTrialButton>
</p>
{clickedFreeTrialButton ? (
<p className="small">{t('refresh_page_after_starting_free_trial')}</p>
) : null}
</div>
)
}

View File

@@ -0,0 +1,23 @@
import { formatTime } from '@/features/utils/format-date'
import { useTranslation } from 'react-i18next'
import { LoadedUpdate } from '../../services/types/update'
function ProjectRestoreChange({
origin,
}: Pick<LoadedUpdate['meta'], 'origin'>) {
const { t } = useTranslation()
if (!origin || origin.kind !== 'project-restore') {
return null
}
return (
<div className="history-version-restore-project">
{t('file_action_restored_project', {
date: formatTime(origin.timestamp, 'Do MMMM, h:mm a'),
})}
</div>
)
}
export default ProjectRestoreChange

View File

@@ -0,0 +1,187 @@
import { forwardRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import ModalError from './modal-error'
import useAbortController from '../../../../shared/hooks/use-abort-controller'
import useAsync from '../../../../shared/hooks/use-async'
import useAddOrRemoveLabels from '../../hooks/use-add-or-remove-labels'
import { useHistoryContext } from '../../context/history-context'
import { deleteLabel } from '../../services/api'
import { isPseudoLabel } from '../../utils/label'
import { LoadedLabel } from '../../services/types/label'
import { debugConsole } from '@/utils/debugging'
import { FormatTimeBasedOnYear } from '@/shared/components/format-time-based-on-year'
import { useEditorContext } from '@/shared/context/editor-context'
import OLTag from '@/features/ui/components/ol/ol-tag'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLTagIcon from '@/features/ui/components/ol/icons/ol-tag-icon'
type TagProps = {
label: LoadedLabel
currentUserId: string
}
const ChangeTag = forwardRef<HTMLElement, TagProps>(
({ label, currentUserId, ...props }: TagProps, ref) => {
const { isProjectOwner } = useEditorContext()
const { t } = useTranslation()
const [showDeleteModal, setShowDeleteModal] = useState(false)
const { projectId } = useHistoryContext()
const { signal } = useAbortController()
const { removeUpdateLabel } = useAddOrRemoveLabels()
const { isLoading, isSuccess, isError, error, reset, runAsync } = useAsync()
const isPseudoCurrentStateLabel = isPseudoLabel(label)
const isOwnedByCurrentUser = !isPseudoCurrentStateLabel
? label.user_id === currentUserId
: null
const showConfirmationModal = (e: React.MouseEvent) => {
e.stopPropagation()
setShowDeleteModal(true)
}
const handleModalExited = () => {
if (!isSuccess) return
if (!isPseudoCurrentStateLabel) {
removeUpdateLabel(label)
}
reset()
}
const localDeleteHandler = () => {
runAsync(deleteLabel(projectId, label.id, signal))
.then(() => setShowDeleteModal(false))
.catch(debugConsole.error)
}
const responseError = error as unknown as {
response: Response
data?: {
message?: string
}
}
const showCloseButton = Boolean(
(isOwnedByCurrentUser || isProjectOwner) && !isPseudoCurrentStateLabel
)
return (
<>
<OLTag
ref={ref}
prepend={<OLTagIcon />}
closeBtnProps={
showCloseButton
? { 'aria-label': t('delete'), onClick: showConfirmationModal }
: undefined
}
className="history-version-badge"
data-testid="history-version-badge"
{...props}
>
{isPseudoCurrentStateLabel
? t('history_label_project_current_state')
: label.comment}
</OLTag>
{!isPseudoCurrentStateLabel && (
<OLModal
show={showDeleteModal}
onExited={handleModalExited}
onHide={() => setShowDeleteModal(false)}
id="delete-history-label"
>
<OLModalHeader>
<OLModalTitle>{t('history_delete_label')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
{isError && <ModalError error={responseError} />}
<p>
{t('history_are_you_sure_delete_label')}&nbsp;
<strong>"{label.comment}"</strong>?
</p>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
disabled={isLoading}
onClick={() => setShowDeleteModal(false)}
>
{t('cancel')}
</OLButton>
<OLButton
variant="danger"
disabled={isLoading}
isLoading={isLoading}
onClick={localDeleteHandler}
>
{t('history_delete_label')}
</OLButton>
</OLModalFooter>
</OLModal>
)}
</>
)
}
)
ChangeTag.displayName = 'ChangeTag'
type LabelBadgesProps = {
showTooltip: boolean
currentUserId: string
label: LoadedLabel
}
function TagTooltip({ label, currentUserId, showTooltip }: LabelBadgesProps) {
const { t } = useTranslation()
const { labels: allLabels } = useHistoryContext()
const isPseudoCurrentStateLabel = isPseudoLabel(label)
const currentLabelData = allLabels?.find(({ id }) => id === label.id)
const labelOwnerName =
currentLabelData && !isPseudoLabel(currentLabelData)
? currentLabelData.user_display_name
: t('anonymous')
return !isPseudoCurrentStateLabel ? (
<OLTooltip
description={
<div className="history-version-label-tooltip">
<div className="history-version-label-tooltip-row">
<b className="history-version-label-tooltip-row-comment">
<OLTagIcon />
&nbsp;
{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>
<FormatTimeBasedOnYear date={label.created_at} />
</time>
</div>
</div>
}
id={label.id}
overlayProps={{ placement: 'left' }}
hidden={!showTooltip}
>
<ChangeTag label={label} currentUserId={currentUserId} />
</OLTooltip>
) : (
<ChangeTag label={label} currentUserId={currentUserId} />
)
}
export default TagTooltip

View File

@@ -0,0 +1,94 @@
import { useTranslation } from 'react-i18next'
import { useHistoryContext } from '../../context/history-context'
import {
getUpdateForVersion,
updateRangeForUpdate,
} from '../../utils/history-details'
import { isAnyVersionMatchingSelection } from '../../utils/label'
import { HistoryContextValue } from '../../context/types/history-context-value'
import { updateRangeUnion } from '../../utils/range'
type ToggleSwitchProps = Pick<
HistoryContextValue,
'labelsOnly' | 'setLabelsOnly'
>
function ToggleSwitch({ labelsOnly, setLabelsOnly }: ToggleSwitchProps) {
const { t } = useTranslation()
const { selection, setSelection, resetSelection, updatesInfo, labels } =
useHistoryContext()
const handleChange = (isLabelsOnly: boolean) => {
if (selection.comparing) {
// using the switch toggle should reset the selection when in `compare` mode
resetSelection()
} else {
if (isLabelsOnly) {
if (isAnyVersionMatchingSelection(labels, selection)) {
resetSelection()
}
} else {
// in labels only mode the `fromV` is equal to `toV` value
// switching to all history mode and triggering immediate comparison with
// an older version would cause a bug if the computation below is skipped.
const { updateRange } = selection
const update = updateRange?.toV
? getUpdateForVersion(updateRange.toV, updatesInfo.updates)
: null
if (
updateRange &&
update &&
(update.fromV !== updateRange.fromV || update.toV !== updateRange.toV)
) {
const range = updateRangeUnion(
updateRangeForUpdate(update),
updateRange
)
setSelection(({ previouslySelectedPathname }) => ({
updateRange: range,
comparing: false,
files: [],
previouslySelectedPathname,
}))
}
}
}
setLabelsOnly(isLabelsOnly)
}
return (
<fieldset className="toggle-switch">
<legend className="sr-only">{t('history_view_a11y_description')}</legend>
<input
type="radio"
name="labels-only-toggle-switch"
checked={!labelsOnly}
onChange={() => handleChange(false)}
className="toggle-switch-input"
id="toggle-switch-all-history"
/>
<label
htmlFor="toggle-switch-all-history"
className="toggle-switch-label"
>
<span>{t('history_view_all')}</span>
</label>
<input
type="radio"
name="labels-only-toggle-switch"
checked={labelsOnly}
onChange={() => handleChange(true)}
className="toggle-switch-input"
id="toggle-switch-labels"
/>
<label htmlFor="toggle-switch-labels" className="toggle-switch-label">
<span>{t('history_view_labels')}</span>
</label>
</fieldset>
)
}
export default ToggleSwitch

View File

@@ -0,0 +1,40 @@
import { useTranslation } from 'react-i18next'
import { formatUserName } from '../../utils/history-details'
import { User } from '../../services/types/shared'
import { Nullable } from '../../../../../../types/utils'
import { getBackgroundColorForUserId } from '@/shared/utils/colors'
type UserNameWithColoredBadgeProps = {
currentUserId: string
user: Nullable<User | { id: string; displayName: string }>
}
function UserNameWithColoredBadge({
user,
currentUserId,
}: UserNameWithColoredBadgeProps) {
const { t } = useTranslation()
let userName: string
if (!user) {
userName = t('anonymous')
} else if (user.id === currentUserId) {
userName = t('you')
} else if ('displayName' in user) {
userName = user.displayName
} else {
userName = formatUserName(user)
}
return (
<>
<span
className="history-version-user-badge-color"
style={{ backgroundColor: getBackgroundColorForUserId(user?.id) }}
/>
<span className="history-version-user-badge-text">{userName}</span>
</>
)
}
export default UserNameWithColoredBadge

View File

@@ -0,0 +1,85 @@
import { useEffect, useMemo } from 'react'
import Toolbar from './toolbar/toolbar'
import Main from './main'
import { Diff, DocDiffResponse } from '../../services/types/doc'
import { useHistoryContext } from '../../context/history-context'
import { diffDoc } from '../../services/api'
import { highlightsFromDiffResponse } from '../../utils/highlights-from-diff-response'
import { useErrorHandler } from 'react-error-boundary'
import useAsync from '../../../../shared/hooks/use-async'
import { useTranslation } from 'react-i18next'
function DiffView() {
const { selection, projectId, loadingFileDiffs } = useHistoryContext()
const { isLoading, data, runAsync } = useAsync<DocDiffResponse>()
const { t } = useTranslation()
const { updateRange, selectedFile } = selection
const handleError = useErrorHandler()
useEffect(() => {
if (!updateRange || !selectedFile?.pathname || loadingFileDiffs) {
return
}
const { fromV, toV } = updateRange
let abortController: AbortController | null = new AbortController()
runAsync(
diffDoc(
projectId,
fromV,
toV,
selectedFile.pathname,
abortController.signal
)
)
.catch(handleError)
.finally(() => {
abortController = null
})
// Abort an existing request before starting a new one or on unmount
return () => {
if (abortController) {
abortController.abort()
}
}
}, [
projectId,
runAsync,
updateRange,
selectedFile,
loadingFileDiffs,
handleError,
])
const diff = useMemo(() => {
let diff: Diff | null
if (!data?.diff) {
diff = null
} else if ('binary' in data.diff) {
diff = { binary: true }
} else {
diff = {
binary: false,
docDiff: highlightsFromDiffResponse(data.diff, t),
}
}
return diff
}, [data, t])
return (
<div className="doc-panel">
<div className="history-header toolbar-container">
<Toolbar diff={diff} selection={selection} />
</div>
<div className="doc-container">
<Main diff={diff} isLoading={isLoading || loadingFileDiffs} />
</div>
</div>
)
}
export default DiffView

View File

@@ -0,0 +1,157 @@
import { useCallback, useEffect, useState } from 'react'
import {
EditorSelection,
EditorState,
Extension,
StateEffect,
} from '@codemirror/state'
import { EditorView, lineNumbers } from '@codemirror/view'
import { indentationMarkers } from '@replit/codemirror-indentation-markers'
import { highlights, setHighlightsEffect } from '../../extensions/highlights'
import { useUserSettingsContext } from '@/shared/context/user-settings-context'
import { theme, Options, setOptionsTheme } from '../../extensions/theme'
import { indentUnit } from '@codemirror/language'
import { Highlight } from '../../services/types/doc'
import useIsMounted from '../../../../shared/hooks/use-is-mounted'
import {
highlightLocations,
highlightLocationsField,
scrollToHighlight,
} from '../../extensions/highlight-locations'
import { useTranslation } from 'react-i18next'
import { inlineBackground } from '../../../source-editor/extensions/inline-background'
import OLButton from '@/features/ui/components/ol/ol-button'
function extensions(themeOptions: Options): Extension[] {
return [
EditorView.editable.of(false),
EditorState.readOnly.of(true),
EditorView.contentAttributes.of({ tabindex: '0' }),
lineNumbers(),
EditorView.lineWrapping,
indentUnit.of(' '), // TODO: Vary this by file type
indentationMarkers({ hideFirstIndent: true, highlightActiveBlock: false }),
highlights(),
highlightLocations(),
theme(themeOptions),
inlineBackground(false),
]
}
function DocumentDiffViewer({
doc,
highlights,
}: {
doc: string
highlights: Highlight[]
}) {
const { userSettings } = useUserSettingsContext()
const { fontFamily, fontSize, lineHeight } = userSettings
const isMounted = useIsMounted()
const { t } = useTranslation()
const [state, setState] = useState(() => {
return EditorState.create({
doc,
extensions: extensions({
fontSize,
fontFamily,
lineHeight,
}),
})
})
const [view] = useState<EditorView>(() => {
return new EditorView({
state,
dispatch: tr => {
view.update([tr])
if (isMounted.current) {
setState(view.state)
}
},
})
})
const highlightLocations = state.field(highlightLocationsField)
// Append the editor view DOM to the container node when mounted
const containerRef = useCallback(
node => {
if (node) {
node.appendChild(view.dom)
}
},
[view]
)
const scrollToPrevious = useCallback(() => {
if (highlightLocations.previous) {
scrollToHighlight(view, highlightLocations.previous)
}
}, [highlightLocations.previous, view])
const scrollToNext = useCallback(() => {
if (highlightLocations.next) {
scrollToHighlight(view, highlightLocations.next)
}
}, [highlightLocations.next, view])
const { before, after } = highlightLocations
useEffect(() => {
const effects: StateEffect<unknown>[] = [setHighlightsEffect.of(highlights)]
if (highlights.length > 0) {
const { from, to } = highlights[0].range
effects.push(
EditorView.scrollIntoView(EditorSelection.range(from, to), {
y: 'center',
})
)
}
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: doc },
effects,
})
}, [doc, highlights, view])
// Update the document diff viewer theme whenever the font size, font family
// or line height user setting changes
useEffect(() => {
view.dispatch(
setOptionsTheme({
fontSize,
fontFamily,
lineHeight,
})
)
}, [view, fontSize, fontFamily, lineHeight])
return (
<div className="document-diff-container">
<div ref={containerRef} className="cm-viewer-container" />
{before > 0 ? (
<OLButton
variant="secondary"
leadingIcon="arrow_upward"
onClick={scrollToPrevious}
className="previous-highlight-button"
>
{t('n_more_updates_above', { count: before })}
</OLButton>
) : null}
{after > 0 ? (
<OLButton
variant="secondary"
leadingIcon="arrow_downward"
onClick={scrollToNext}
className="next-highlight-button"
>
{t('n_more_updates_below', { count: after })}
</OLButton>
) : null}
</div>
)
}
export default DocumentDiffViewer

View File

@@ -0,0 +1,40 @@
import { Nullable } from '../../../../../../types/utils'
import { Diff } from '../../services/types/doc'
import DocumentDiffViewer from './document-diff-viewer'
import LoadingSpinner from '../../../../shared/components/loading-spinner'
import { useTranslation } from 'react-i18next'
import OLNotification from '@/features/ui/components/ol/ol-notification'
type MainProps = {
diff: Nullable<Diff>
isLoading: boolean
}
function Main({ diff, isLoading }: MainProps) {
const { t } = useTranslation()
if (isLoading) {
return <LoadingSpinner />
}
if (!diff) {
return <div className="history-content">No document</div>
}
if (diff.binary) {
return (
<div className="history-content">
<OLNotification content={t('binary_history_error')} type="info" />
</div>
)
}
if (diff.docDiff) {
const { doc, highlights } = diff.docDiff
return <DocumentDiffViewer doc={doc} highlights={highlights} />
}
return null
}
export default Main

View File

@@ -0,0 +1,47 @@
import { formatTime } from '@/features/utils/format-date'
import { useMemo } from 'react'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
import { useTranslation } from 'react-i18next'
type RestoreFileConfirmModalProps = {
show: boolean
timestamp: number
onConfirm: () => void
onHide: () => void
}
export function RestoreFileConfirmModal({
show,
timestamp,
onConfirm,
onHide,
}: RestoreFileConfirmModalProps) {
const { t } = useTranslation()
const date = useMemo(() => formatTime(timestamp, 'Do MMMM'), [timestamp])
const time = useMemo(() => formatTime(timestamp, 'h:mm a'), [timestamp])
return (
<OLModal show={show} onHide={onHide}>
<OLModalHeader closeButton>
<OLModalTitle>{t('restore_file_confirmation_title')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
{t('restore_file_confirmation_message', { date, time })}
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={onHide}>
{t('cancel')}
</OLButton>
<OLButton variant="primary" onClick={onConfirm}>
{t('restore')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}

View File

@@ -0,0 +1,30 @@
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
import { useTranslation } from 'react-i18next'
export function RestoreFileErrorModal({
resetErrorBoundary,
}: {
resetErrorBoundary: VoidFunction
}) {
const { t } = useTranslation()
return (
<OLModal show onHide={resetErrorBoundary}>
<OLModalHeader closeButton>
<OLModalTitle>{t('restore_file_error_title')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>{t('restore_file_error_message')}</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={resetErrorBoundary}>
{t('close')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}

View File

@@ -0,0 +1,36 @@
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
import { useTranslation } from 'react-i18next'
export function RestoreProjectErrorModal({
resetErrorBoundary,
}: {
resetErrorBoundary: VoidFunction
}) {
const { t } = useTranslation()
return (
<OLModal show onHide={resetErrorBoundary}>
<OLModalHeader closeButton>
<OLModalTitle>
{t('an_error_occured_while_restoring_project')}
</OLModalTitle>
</OLModalHeader>
<OLModalBody>
{t(
'there_was_a_problem_restoring_the_project_please_try_again_in_a_few_moments_or_contact_us'
)}
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={resetErrorBoundary}>
{t('close')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}

View File

@@ -0,0 +1,60 @@
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import { formatDate } from '@/utils/dates'
import { useCallback } from 'react'
import OLButton from '@/features/ui/components/ol/ol-button'
import { useTranslation } from 'react-i18next'
type RestoreProjectModalProps = {
setShow: React.Dispatch<React.SetStateAction<boolean>>
show: boolean
isRestoring: boolean
endTimestamp: number
onRestore: () => void
}
export const RestoreProjectModal = ({
setShow,
show,
endTimestamp,
isRestoring,
onRestore,
}: RestoreProjectModalProps) => {
const { t } = useTranslation()
const onCancel = useCallback(() => {
setShow(false)
}, [setShow])
return (
<OLModal onHide={() => setShow(false)} show={show}>
<OLModalHeader>
<OLModalTitle>{t('restore_this_version')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<p>
{t('your_current_project_will_revert_to_the_version_from_time', {
timestamp: formatDate(endTimestamp),
})}
</p>
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={onCancel} disabled={isRestoring}>
{t('cancel')}
</OLButton>
<OLButton
variant="primary"
onClick={onRestore}
disabled={isRestoring}
isLoading={isRestoring}
>
{t('restore')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}

View File

@@ -0,0 +1,47 @@
import { Trans } from 'react-i18next'
import { formatTime } from '../../../../utils/format-date'
import type { HistoryContextValue } from '../../../context/types/history-context-value'
type ToolbarDatetimeProps = {
selection: HistoryContextValue['selection']
}
export default function ToolbarDatetime({ selection }: ToolbarDatetimeProps) {
return (
<div className="history-react-toolbar-datetime">
{selection.comparing ? (
<Trans
i18nKey="comparing_from_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'
),
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
) : (
<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'
),
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,36 @@
import { useTranslation } from 'react-i18next'
import type { HistoryContextValue } from '../../../context/types/history-context-value'
import type { Diff } from '../../../services/types/doc'
import type { Nullable } from '../../../../../../../types/utils'
type ToolbarFileInfoProps = {
diff: Nullable<Diff>
selection: HistoryContextValue['selection']
}
export default function ToolbarFileInfo({
diff,
selection,
}: ToolbarFileInfoProps) {
const { t } = useTranslation()
return (
<div className="history-react-toolbar-file-info">
{t('x_changes_in', {
count: diff?.docDiff?.highlights?.length ?? 0,
})}
&nbsp;
<strong>{getFileName(selection)}</strong>
</div>
)
}
function getFileName(selection: HistoryContextValue['selection']) {
const filePathParts = selection?.selectedFile?.pathname?.split('/')
let fileName
if (filePathParts) {
fileName = filePathParts[filePathParts.length - 1]
}
return fileName
}

View File

@@ -0,0 +1,28 @@
import OLButton from '@/features/ui/components/ol/ol-button'
import { useTranslation } from 'react-i18next'
import { useRestoreDeletedFile } from '../../../context/hooks/use-restore-deleted-file'
import type { HistoryContextValue } from '../../../context/types/history-context-value'
type ToolbarRestoreFileButtonProps = {
selection: HistoryContextValue['selection']
}
export default function ToolbarRestoreFileButton({
selection,
}: ToolbarRestoreFileButtonProps) {
const { t } = useTranslation()
const { restoreDeletedFile, isLoading } = useRestoreDeletedFile()
return (
<OLButton
variant="secondary"
size="sm"
className="history-react-toolbar-restore-file-button"
isLoading={isLoading}
onClick={() => restoreDeletedFile(selection)}
>
{t('restore_file')}
</OLButton>
)
}

View File

@@ -0,0 +1,51 @@
import OLButton from '@/features/ui/components/ol/ol-button'
import { useTranslation } from 'react-i18next'
import type { HistoryContextValue } from '../../../context/types/history-context-value'
import withErrorBoundary from '@/infrastructure/error-boundary'
import { RestoreFileConfirmModal } from '../modals/restore-file-confirm-modal'
import { useState } from 'react'
import { RestoreFileErrorModal } from '../modals/restore-file-error-modal'
import { useRestoreSelectedFile } from '@/features/history/context/hooks/use-restore-selected-file'
type ToolbarRevertingFileButtonProps = {
selection: HistoryContextValue['selection']
}
function ToolbarRestoreFileToVersionButton({
selection,
}: ToolbarRevertingFileButtonProps) {
const { t } = useTranslation()
const { restoreSelectedFile, isLoading } = useRestoreSelectedFile()
const [showConfirmModal, setShowConfirmModal] = useState(false)
if (!selection.updateRange || !selection.selectedFile) {
return null
}
return (
<>
<RestoreFileConfirmModal
show={showConfirmModal}
timestamp={selection.updateRange.toVTimestamp}
onConfirm={() => {
setShowConfirmModal(false)
restoreSelectedFile(selection)
}}
onHide={() => setShowConfirmModal(false)}
/>
<OLButton
variant="secondary"
size="sm"
isLoading={isLoading}
onClick={() => setShowConfirmModal(true)}
>
{t('restore_file_version')}
</OLButton>
</>
)
}
export default withErrorBoundary(
ToolbarRestoreFileToVersionButton,
RestoreFileErrorModal
)

View File

@@ -0,0 +1,51 @@
import type { Nullable } from '../../../../../../../types/utils'
import type { Diff } from '../../../services/types/doc'
import type { HistoryContextValue } from '../../../context/types/history-context-value'
import ToolbarDatetime from './toolbar-datetime'
import ToolbarFileInfo from './toolbar-file-info'
import ToolbarRestoreFileButton from './toolbar-restore-file-button'
import { isFileRemoved } from '../../../utils/file-diff'
import ToolbarRestoreFileToVersionButton from './toolbar-restore-file-to-version-button'
import { useFeatureFlag } from '@/shared/context/split-test-context'
import SplitTestBadge from '@/shared/components/split-test-badge'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
type ToolbarProps = {
diff: Nullable<Diff>
selection: HistoryContextValue['selection']
}
export default function Toolbar({ diff, selection }: ToolbarProps) {
const { write } = usePermissionsContext()
const hasRestoreFileToVersion = useFeatureFlag('revert-file')
const showRestoreFileToVersionButton =
hasRestoreFileToVersion && selection.selectedFile && write
const showRestoreFileButton =
selection.selectedFile &&
isFileRemoved(selection.selectedFile) &&
!showRestoreFileToVersionButton &&
write
return (
<div className="history-react-toolbar">
<ToolbarDatetime selection={selection} />
{selection.selectedFile?.pathname ? (
<ToolbarFileInfo diff={diff} selection={selection} />
) : null}
{showRestoreFileButton ? (
<ToolbarRestoreFileButton selection={selection} />
) : null}
{showRestoreFileToVersionButton ? (
<>
<ToolbarRestoreFileToVersionButton selection={selection} />
<SplitTestBadge
splitTestName="revert-file"
displayOnVariants={['enabled']}
/>
</>
) : null}
</div>
)
}

View File

@@ -0,0 +1,47 @@
import { memo } from 'react'
import classNames from 'classnames'
import HistoryFileTreeItem from './history-file-tree-item'
import iconTypeFromName from '../../../file-tree/util/icon-type-from-name'
import type { FileDiff } from '../../services/types/file'
import MaterialIcon from '@/shared/components/material-icon'
type HistoryFileTreeDocProps = {
file: FileDiff
name: string
selected: boolean
onClick: (file: FileDiff, event: React.MouseEvent<HTMLLIElement>) => void
onKeyDown: (file: FileDiff, event: React.KeyboardEvent<HTMLLIElement>) => void
}
function HistoryFileTreeDoc({
file,
name,
selected,
onClick,
onKeyDown,
}: HistoryFileTreeDocProps) {
return (
<li
role="treeitem"
className={classNames({ selected })}
onClick={e => onClick(file, e)}
onKeyDown={e => onKeyDown(file, e)}
aria-selected={selected}
aria-label={name}
tabIndex={0}
>
<HistoryFileTreeItem
name={name}
operation={'operation' in file ? file.operation : undefined}
icons={
<MaterialIcon
type={iconTypeFromName(name)}
className="file-tree-icon"
/>
}
/>
</li>
)
}
export default memo(HistoryFileTreeDoc)

View File

@@ -0,0 +1,87 @@
import classNames from 'classnames'
import HistoryFileTreeDoc from './history-file-tree-doc'
import HistoryFileTreeFolder from './history-file-tree-folder'
import { ReactNode, useCallback } from 'react'
import type { HistoryFileTree, HistoryDoc } from '../../utils/file-tree'
import { useHistoryContext } from '../../context/history-context'
import { FileDiff } from '../../services/types/file'
import { fileFinalPathname } from '../../utils/file-diff'
type HistoryFileTreeFolderListProps = {
folders: HistoryFileTree[]
docs: HistoryDoc[]
rootClassName?: string
children?: ReactNode
}
function HistoryFileTreeFolderList({
folders,
docs,
rootClassName,
children,
}: HistoryFileTreeFolderListProps) {
const { selection, setSelection } = useHistoryContext()
const handleEvent = useCallback(
(file: FileDiff) => {
setSelection(prevSelection => {
if (file.pathname !== prevSelection.selectedFile?.pathname) {
return {
...prevSelection,
selectedFile: file,
previouslySelectedPathname: file.pathname,
}
}
return prevSelection
})
},
[setSelection]
)
const handleClick = useCallback(
(file: FileDiff) => {
handleEvent(file)
},
[handleEvent]
)
const handleKeyDown = useCallback(
(file: FileDiff, event: React.KeyboardEvent<HTMLLIElement>) => {
if (event.key === 'Enter' || event.key === ' ') {
handleEvent(file)
}
},
[handleEvent]
)
return (
<ul className={classNames('list-unstyled', rootClassName)} role="tree">
{folders.map(folder => (
<HistoryFileTreeFolder
key={folder.name}
name={folder.name}
folders={folder.folders}
docs={folder.docs ?? []}
/>
))}
{docs.map(doc => (
<HistoryFileTreeDoc
key={doc.pathname}
name={doc.name}
file={doc}
selected={
!!selection.selectedFile &&
fileFinalPathname(selection.selectedFile) === doc.pathname
}
onClick={handleClick}
onKeyDown={handleKeyDown}
/>
))}
{children}
</ul>
)
}
export default HistoryFileTreeFolderList

View File

@@ -0,0 +1,88 @@
import { useState, memo } from 'react'
import { useTranslation } from 'react-i18next'
import HistoryFileTreeItem from './history-file-tree-item'
import HistoryFileTreeFolderList from './history-file-tree-folder-list'
import type { HistoryDoc, HistoryFileTree } from '../../utils/file-tree'
import MaterialIcon from '@/shared/components/material-icon'
type HistoryFileTreeFolderProps = {
name: string
folders: HistoryFileTree[]
docs: HistoryDoc[]
}
function hasChanges(fileTree: HistoryFileTree): boolean {
const hasSameLevelChanges = fileTree.docs?.some(
(doc: HistoryDoc) => (doc as any).operation !== undefined
)
if (hasSameLevelChanges) {
return true
}
const hasNestedChanges = fileTree.folders?.some(folder => {
return hasChanges(folder)
})
if (hasNestedChanges) {
return true
}
return false
}
function HistoryFileTreeFolder({
name,
folders,
docs,
}: HistoryFileTreeFolderProps) {
const { t } = useTranslation()
const [expanded, setExpanded] = useState(() => {
return hasChanges({ name, folders, docs })
})
const icons = (
<>
<button
onClick={() => setExpanded(!expanded)}
aria-label={expanded ? t('collapse') : t('expand')}
className="history-file-tree-folder-button"
>
<MaterialIcon
type={expanded ? 'expand_more' : 'chevron_right'}
className="file-tree-expand-icon"
/>
</button>
<MaterialIcon
type={expanded ? 'folder_open' : 'folder'}
className="file-tree-folder-icon"
/>
</>
)
return (
<>
<li
// FIXME
// eslint-disable-next-line jsx-a11y/role-has-required-aria-props
role="treeitem"
aria-expanded={expanded}
aria-label={name}
tabIndex={0}
onClick={() => setExpanded(!expanded)}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setExpanded(!expanded)
}
}}
>
<HistoryFileTreeItem name={name} icons={icons} />
</li>
{expanded ? (
<HistoryFileTreeFolderList folders={folders} docs={docs} />
) : null}
</>
)
}
export default memo(HistoryFileTreeFolder)

View File

@@ -0,0 +1,34 @@
import classNames from 'classnames'
import type { ReactNode } from 'react'
import type { FileOperation } from '../../services/types/file-operation'
import OLTag from '@/features/ui/components/ol/ol-tag'
type FileTreeItemProps = {
name: string
operation?: FileOperation
icons: ReactNode
}
export default function HistoryFileTreeItem({
name,
operation,
icons,
}: FileTreeItemProps) {
return (
<div className="history-file-tree-item" role="presentation">
{icons}
<div className="history-file-tree-item-name-wrapper">
<div
className={classNames('history-file-tree-item-name', {
strikethrough: operation === 'removed',
})}
>
{name}
</div>
{operation && (
<OLTag className="history-file-tree-item-badge">{operation}</OLTag>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,35 @@
import { useMemo } from 'react'
import { orderBy, reduce } from 'lodash'
import { useHistoryContext } from '../context/history-context'
import {
fileTreeDiffToFileTreeData,
reducePathsToTree,
} from '../utils/file-tree'
import HistoryFileTreeFolderList from './file-tree/history-file-tree-folder-list'
export default function HistoryFileTree() {
const { selection } = useHistoryContext()
const fileTree = useMemo(
() => reduce(selection.files, reducePathsToTree, []),
[selection.files]
)
const sortedFileTree = useMemo(
() => orderBy(fileTree, ['-type', 'operation', 'name']),
[fileTree]
)
const mappedFileTree = useMemo(
() => fileTreeDiffToFileTreeData(sortedFileTree),
[sortedFileTree]
)
return (
<HistoryFileTreeFolderList
folders={mappedFileTree.folders}
docs={mappedFileTree.docs ?? []}
rootClassName="history-file-tree-list"
/>
)
}