first commit
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
function HistoryResyncChange() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return <div>{t('history_resync')}</div>
|
||||
}
|
||||
|
||||
export default HistoryResyncChange
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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')}
|
||||
<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 />
|
||||
|
||||
{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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
})}
|
||||
|
||||
<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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user