Merge pull request #20860 from overleaf/td-bs5-history-versions-list

Migrate history versions list to Bootstrap 5

GitOrigin-RevId: 4e006ad353cb11eadaefb2df41d2b8591003c664
This commit is contained in:
Tim Down 2024-10-14 09:00:41 +01:00 committed by Copybot
parent 8c342dc226
commit 4138f9707a
57 changed files with 1836 additions and 416 deletions

View file

@ -4,6 +4,7 @@
"function-url-quotes": null, "function-url-quotes": null,
"no-descending-specificity": null, "no-descending-specificity": null,
"scss/at-extend-no-missing-placeholder": null, "scss/at-extend-no-missing-placeholder": null,
"scss/operator-no-newline-after": null "scss/operator-no-newline-after": null,
"property-no-vendor-prefix": [true, { "ignoreProperties": ["mask-image"] }]
} }
} }

View file

@ -1,8 +1,15 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Modal, FormGroup, FormControl } from 'react-bootstrap' 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 ModalError from './modal-error'
import AccessibleModal from '../../../../shared/components/accessible-modal' 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 useAsync from '../../../../shared/hooks/use-async'
import useAbortController from '../../../../shared/hooks/use-abort-controller' import useAbortController from '../../../../shared/hooks/use-abort-controller'
import useAddOrRemoveLabels from '../../hooks/use-add-or-remove-labels' import useAddOrRemoveLabels from '../../hooks/use-add-or-remove-labels'
@ -11,6 +18,7 @@ import { addLabel } from '../../services/api'
import { Label } from '../../services/types/label' import { Label } from '../../services/types/label'
import { useRefWithAutoFocus } from '../../../../shared/hooks/use-ref-with-auto-focus' import { useRefWithAutoFocus } from '../../../../shared/hooks/use-ref-with-auto-focus'
import { debugConsole } from '@/utils/debugging' import { debugConsole } from '@/utils/debugging'
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
type AddLabelModalProps = { type AddLabelModalProps = {
show: boolean show: boolean
@ -71,51 +79,55 @@ function AddLabelModal({ show, setShow, version }: AddLabelModalProps) {
} }
return ( return (
<AccessibleModal <OLModal
show={show} show={show}
onExited={handleModalExited} onExited={handleModalExited}
onHide={() => setShow(false)} onHide={() => setShow(false)}
id="add-history-label" id="add-history-label"
> >
<Modal.Header> <OLModalHeader>
<Modal.Title>{t('history_add_label')}</Modal.Title> <OLModalTitle>{t('history_add_label')}</OLModalTitle>
</Modal.Header> </OLModalHeader>
<form onSubmit={handleSubmit}> <OLForm onSubmit={handleSubmit}>
<Modal.Body> <OLModalBody>
{isError && <ModalError error={responseError} />} {isError && <ModalError error={responseError} />}
<FormGroup> <OLFormGroup>
<input <OLFormControl
ref={autoFocusedRef} ref={autoFocusedRef}
className="form-control"
type="text" type="text"
placeholder={t('history_new_label_name')} placeholder={t('history_new_label_name')}
required required
value={comment} value={comment}
onChange={( onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
e: React.ChangeEvent<HTMLInputElement & FormControl> setComment(e.target.value)
) => setComment(e.target.value)} }
/> />
</FormGroup> </OLFormGroup>
</Modal.Body> </OLModalBody>
<Modal.Footer> <OLModalFooter>
<button <OLButton
type="button" variant="secondary"
className="btn btn-secondary"
disabled={isLoading} disabled={isLoading}
onClick={() => setShow(false)} onClick={() => setShow(false)}
> >
{t('cancel')} {t('cancel')}
</button> </OLButton>
<button <OLButton
type="submit" type="submit"
className="btn btn-primary" variant="primary"
disabled={isLoading || !comment.length} disabled={isLoading || !comment.length}
isLoading={isLoading}
bs3Props={{
loading: isLoading
? t('history_adding_label')
: t('history_add_label'),
}}
> >
{isLoading ? t('history_adding_label') : t('history_add_label')} {t('history_add_label')}
</button> </OLButton>
</Modal.Footer> </OLModalFooter>
</form> </OLForm>
</AccessibleModal> </OLModal>
) )
} }

View file

@ -8,7 +8,8 @@ import { useUserContext } from '../../../../shared/context/user-context'
import useDropdownActiveItem from '../../hooks/use-dropdown-active-item' import useDropdownActiveItem from '../../hooks/use-dropdown-active-item'
import { useHistoryContext } from '../../context/history-context' import { useHistoryContext } from '../../context/history-context'
import { useEditorContext } from '../../../../shared/context/editor-context' import { useEditorContext } from '../../../../shared/context/editor-context'
import { Overlay, Popover } from 'react-bootstrap' 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 Close from '@/shared/components/close'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import MaterialIcon from '@/shared/components/material-icon' import MaterialIcon from '@/shared/components/material-icon'
@ -173,18 +174,29 @@ function AllHistoryList() {
if (showHistoryTutorial) { if (showHistoryTutorial) {
popover = ( popover = (
<Overlay <OLOverlay
placement="left" placement="left-start"
show={showHistoryTutorial} show={showHistoryTutorial}
rootClose rootClose
onHide={hidePopover} onHide={hidePopover}
// using scrollerRef to position the popover in the middle of the viewport // using scrollerRef to position the popover in the middle of the viewport
target={scrollerRef.current ?? undefined} target={scrollerRef.current}
shouldUpdatePosition // Only used in Bootstrap 5. In Bootstrap 3 this is done with CSS.
popperConfig={{
modifiers: [
{
name: 'offset',
options: {
offset: [10, 10],
},
},
],
}}
bs3Props={{ shouldUpdatePosition: true }}
> >
<Popover <OLPopover
id="popover-toolbar-overflow" id="popover-react-history-tutorial"
arrowOffsetTop={10} bs3Props={{ arrowOffsetTop: 10 }}
title={ title={
<span> <span>
{t('react_history_tutorial_title')}{' '} {t('react_history_tutorial_title')}{' '}
@ -212,23 +224,23 @@ function AllHistoryList() {
<a href="https://www.overleaf.com/learn/latex/Using_the_History_feature" />, // eslint-disable-line jsx-a11y/anchor-has-content, react/jsx-key <a href="https://www.overleaf.com/learn/latex/Using_the_History_feature" />, // eslint-disable-line jsx-a11y/anchor-has-content, react/jsx-key
]} ]}
/> />
</Popover> </OLPopover>
</Overlay> </OLOverlay>
) )
} else if (showRestorePromo) { } else if (showRestorePromo) {
popover = ( popover = (
<Overlay <OLOverlay
placement="left" placement="left-start"
show={showRestorePromo} show={showRestorePromo}
rootClose rootClose
onHide={hidePopover} onHide={hidePopover}
// using scrollerRef to position the popover in the middle of the viewport // using scrollerRef to position the popover in the middle of the viewport
target={scrollerRef.current ?? undefined} target={scrollerRef.current}
shouldUpdatePosition bs3Props={{ shouldUpdatePosition: true }}
> >
<Popover <OLPopover
id="popover-toolbar-overflow" id="popover-history-restore-promo"
arrowOffsetTop={10} bs3Props={{ arrowOffsetTop: 10 }}
title={ title={
<span> <span>
{t('history_restore_promo_title')} {t('history_restore_promo_title')}
@ -255,8 +267,8 @@ function AllHistoryList() {
/>, />,
]} ]}
/> />
</Popover> </OLPopover>
</Overlay> </OLOverlay>
) )
} }
@ -330,7 +342,9 @@ function AllHistoryList() {
{showNonOwnerPaywall ? <NonOwnerPaywallPrompt /> : null} {showNonOwnerPaywall ? <NonOwnerPaywallPrompt /> : null}
{updatesLoadingState === 'loadingInitial' || {updatesLoadingState === 'loadingInitial' ||
updatesLoadingState === 'loadingUpdates' ? ( updatesLoadingState === 'loadingUpdates' ? (
<LoadingSpinner /> <div className="history-all-versions-loading">
<LoadingSpinner />
</div>
) : null} ) : null}
</div> </div>
) )

View file

@ -1,9 +1,15 @@
import { useRef, useEffect, ReactNode } from 'react' import React, { useRef, useEffect, ReactNode } from 'react'
import { Dropdown } from 'react-bootstrap' import { Dropdown as BS3Dropdown } from 'react-bootstrap'
import DropdownToggleWithTooltip from '../../../../../shared/components/dropdown/dropdown-toggle-with-tooltip' import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import DropdownMenuWithRef from '../../../../../shared/components/dropdown/dropdown-menu-with-ref' import {
Dropdown,
DropdownMenu,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
import BS3DropdownToggleWithTooltip from '../../../../ui/components/bootstrap-3/dropdown-toggle-with-tooltip'
import BS5DropdownToggleWithTooltip from '@/features/ui/components/bootstrap-5/dropdown-toggle-with-tooltip'
import DropdownMenuWithRef from '../../../../ui/components/bootstrap-3/dropdown-menu-with-ref'
type DropdownMenuProps = { type ActionDropdownProps = {
id: string id: string
children: React.ReactNode children: React.ReactNode
parentSelector?: string parentSelector?: string
@ -13,7 +19,7 @@ type DropdownMenuProps = {
setIsOpened: (isOpened: boolean) => void setIsOpened: (isOpened: boolean) => void
} }
function ActionsDropdown({ function BS3ActionsDropdown({
id, id,
children, children,
parentSelector, parentSelector,
@ -21,7 +27,7 @@ function ActionsDropdown({
iconTag, iconTag,
setIsOpened, setIsOpened,
toolTipDescription, toolTipDescription,
}: DropdownMenuProps) { }: ActionDropdownProps) {
const menuRef = useRef<HTMLElement>() const menuRef = useRef<HTMLElement>()
// handle the placement of the dropdown above or below the toggle button // handle the placement of the dropdown above or below the toggle button
@ -47,14 +53,14 @@ function ActionsDropdown({
}) })
return ( return (
<Dropdown <BS3Dropdown
id={`history-version-dropdown-${id}`} id={`history-version-dropdown-${id}`}
pullRight pullRight
open={isOpened} open={isOpened}
onToggle={open => setIsOpened(open)} onToggle={open => setIsOpened(open)}
className="pull-right" className="pull-right"
> >
<DropdownToggleWithTooltip <BS3DropdownToggleWithTooltip
bsRole="toggle" bsRole="toggle"
className="history-version-dropdown-menu-btn" className="history-version-dropdown-menu-btn"
isOpened={isOpened} isOpened={isOpened}
@ -65,7 +71,7 @@ function ActionsDropdown({
}} }}
> >
{iconTag} {iconTag}
</DropdownToggleWithTooltip> </BS3DropdownToggleWithTooltip>
<DropdownMenuWithRef <DropdownMenuWithRef
bsRole="menu" bsRole="menu"
className="history-version-dropdown-menu" className="history-version-dropdown-menu"
@ -73,8 +79,49 @@ function ActionsDropdown({
> >
{children} {children}
</DropdownMenuWithRef> </DropdownMenuWithRef>
</BS3Dropdown>
)
}
function BS5ActionsDropdown({
id,
children,
isOpened,
iconTag,
setIsOpened,
toolTipDescription,
}: Omit<ActionDropdownProps, 'parentSelector'>) {
return (
<Dropdown
align="end"
className="float-end"
show={isOpened}
onToggle={open => setIsOpened(open)}
>
<BS5DropdownToggleWithTooltip
id={`history-version-dropdown-${id}`}
className="history-version-dropdown-menu-btn"
aria-label={toolTipDescription}
toolTipDescription={toolTipDescription}
overlayTriggerProps={{ placement: 'bottom' }}
tooltipProps={{ hidden: isOpened }}
>
{iconTag}
</BS5DropdownToggleWithTooltip>
<DropdownMenu className="history-version-dropdown-menu">
{children}
</DropdownMenu>
</Dropdown> </Dropdown>
) )
} }
function ActionsDropdown(props: ActionDropdownProps) {
return (
<BootstrapVersionSwitcher
bs3={<BS3ActionsDropdown {...props} />}
bs5={<BS5ActionsDropdown {...props} />}
/>
)
}
export default ActionsDropdown export default ActionsDropdown

View file

@ -40,10 +40,7 @@ function CompareVersionDropdownContentAllHistory({
closeDropdown={closeDropdown} closeDropdown={closeDropdown}
text={t('history_compare_up_to_this_version')} text={t('history_compare_up_to_this_version')}
icon={ icon={
<MaterialIcon <MaterialIcon type="align_start" className="history-dropdown-icon" />
type="align_start"
className="history-dropdown-icon p-1"
/>
} }
/> />
<CompareDropDownItem <CompareDropDownItem
@ -56,10 +53,7 @@ function CompareVersionDropdownContentAllHistory({
closeDropdown={closeDropdown} closeDropdown={closeDropdown}
text={t('history_compare_from_this_version')} text={t('history_compare_from_this_version')}
icon={ icon={
<MaterialIcon <MaterialIcon type="align_end" className="history-dropdown-icon" />
type="align_end"
className="history-dropdown-icon p-1"
/>
} }
/> />
</> </>
@ -100,10 +94,7 @@ function CompareVersionDropdownContentLabelsList({
closeDropdown={closeDropdownLabels} closeDropdown={closeDropdownLabels}
text={t('history_compare_up_to_this_version')} text={t('history_compare_up_to_this_version')}
icon={ icon={
<MaterialIcon <MaterialIcon type="align_start" className="history-dropdown-icon" />
type="align_start"
className="history-dropdown-icon p-1"
/>
} }
/> />
<CompareDropDownItem <CompareDropDownItem
@ -116,10 +107,7 @@ function CompareVersionDropdownContentLabelsList({
closeDropdown={closeDropdownLabels} closeDropdown={closeDropdownLabels}
text={t('history_compare_from_this_version')} text={t('history_compare_from_this_version')}
icon={ icon={
<MaterialIcon <MaterialIcon type="align_end" className="history-dropdown-icon" />
type="align_end"
className="history-dropdown-icon p-1"
/>
} }
/> />
</> </>

View file

@ -27,7 +27,7 @@ function CompareVersionDropdown({
<MaterialIcon <MaterialIcon
type="align_space_even" type="align_space_even"
className="history-dropdown-icon" className="history-dropdown-icon"
accessibilityLabel="compare drop down" accessibilityLabel={t('compare')}
/> />
} }
> >

View file

@ -1,6 +1,8 @@
import ActionsDropdown from './actions-dropdown' import ActionsDropdown from './actions-dropdown'
import Icon from '../../../../../shared/components/icon' import Icon from '../../../../../shared/components/icon'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import MaterialIcon from '@/shared/components/material-icon'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
type HistoryDropdownProps = { type HistoryDropdownProps = {
children: React.ReactNode children: React.ReactNode
@ -23,7 +25,17 @@ function HistoryDropdown({
toolTipDescription={t('more_actions')} toolTipDescription={t('more_actions')}
setIsOpened={setIsOpened} setIsOpened={setIsOpened}
iconTag={ iconTag={
<Icon type="ellipsis-v" accessibilityLabel={t('more_actions')} /> <BootstrapVersionSwitcher
bs3={
<Icon type="ellipsis-v" accessibilityLabel={t('more_actions')} />
}
bs5={
<MaterialIcon
type="more_vert"
accessibilityLabel={t('more_actions')}
/>
}
/>
} }
parentSelector="[data-history-version-list-container]" parentSelector="[data-history-version-list-container]"
> >

View file

@ -1,7 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { MenuItem } from 'react-bootstrap' import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
import Icon from '../../../../../../shared/components/icon' import OLTagIcon from '@/features/ui/components/ol/icons/ol-tag-icon'
import AddLabelModal from '../../add-label-modal' import AddLabelModal from '../../add-label-modal'
type DownloadProps = { type DownloadProps = {
@ -26,9 +26,15 @@ function AddLabel({
return ( return (
<> <>
<MenuItem onClick={handleClick} {...props}> <OLDropdownMenuItem
<Icon type="tag" fw /> {t('history_label_this_version')} onClick={handleClick}
</MenuItem> leadingIcon={<OLTagIcon />}
as="button"
className="dropdown-item-material-icon-small"
{...props}
>
{t('history_label_this_version')}
</OLDropdownMenuItem>
<AddLabelModal <AddLabelModal
show={showModal} show={showModal}
setShow={setShowModal} setShow={setShowModal}

View file

@ -1,8 +1,7 @@
import Icon from '../../../../../../shared/components/icon'
import { useHistoryContext } from '../../../../context/history-context' import { useHistoryContext } from '../../../../context/history-context'
import { UpdateRange } from '../../../../services/types/update' import { UpdateRange } from '../../../../services/types/update'
import { ReactNode } from 'react' import { ReactNode } from 'react'
import { Button, MenuItem } from 'react-bootstrap' import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
type CompareProps = { type CompareProps = {
comparisonRange: UpdateRange comparisonRange: UpdateRange
@ -15,12 +14,12 @@ function CompareDropDownItem({
comparisonRange, comparisonRange,
text, text,
closeDropdown, closeDropdown,
icon = <Icon type="exchange" fw />, icon,
...props ...props
}: CompareProps) { }: CompareProps) {
const { setSelection } = useHistoryContext() const { setSelection } = useHistoryContext()
const handleCompareVersion = (e: React.MouseEvent<Button>) => { const handleCompareVersion = (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
closeDropdown() closeDropdown()
@ -33,10 +32,15 @@ function CompareDropDownItem({
} }
return ( return (
<MenuItem {...props} onClick={handleCompareVersion}> <OLDropdownMenuItem
{icon} {...props}
<span className="">{text}</span> leadingIcon={icon}
</MenuItem> as="button"
onClick={handleCompareVersion}
className="dropdown-item-material-icon-small"
>
{text}
</OLDropdownMenuItem>
) )
} }

View file

@ -1,13 +1,12 @@
import Icon from '../../../../../../shared/components/icon'
import { useHistoryContext } from '../../../../context/history-context' import { useHistoryContext } from '../../../../context/history-context'
import { UpdateRange } from '../../../../services/types/update' import { UpdateRange } from '../../../../services/types/update'
import { ReactNode } from 'react' import { ReactNode } from 'react'
import Tooltip from '../../../../../../shared/components/tooltip' import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import { Button } from 'react-bootstrap' import { bsVersion } from '@/features/utils/bootstrap-5'
type CompareProps = { type CompareProps = {
comparisonRange: UpdateRange comparisonRange: UpdateRange
icon?: ReactNode icon: ReactNode
toolTipDescription?: string toolTipDescription?: string
closeDropdown: () => void closeDropdown: () => void
} }
@ -16,12 +15,11 @@ function Compare({
comparisonRange, comparisonRange,
closeDropdown, closeDropdown,
toolTipDescription, toolTipDescription,
icon = <Icon type="exchange" fw />, icon,
...props
}: CompareProps) { }: CompareProps) {
const { setSelection } = useHistoryContext() const { setSelection } = useHistoryContext()
const handleCompareVersion = (e: React.MouseEvent<Button>) => { const handleCompareVersion = (e: { stopPropagation: () => void }) => {
e.stopPropagation() e.stopPropagation()
closeDropdown() closeDropdown()
@ -34,20 +32,18 @@ function Compare({
} }
return ( return (
<Tooltip <OLTooltip
description={toolTipDescription} description={toolTipDescription}
id="compare-btn" id="compare-btn"
overlayProps={{ placement: 'left' }} overlayProps={{ placement: 'left' }}
> >
<Button <button className="history-compare-btn" onClick={handleCompareVersion}>
bsStyle={null} <span className={bsVersion({ bs3: 'sr-only', bs5: 'visually-hidden' })}>
className="history-compare-btn" {toolTipDescription}
onClick={handleCompareVersion} </span>
>
<span className="sr-only">{toolTipDescription}</span>
{icon} {icon}
</Button> </button>
</Tooltip> </OLTooltip>
) )
} }

View file

@ -1,5 +1,7 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { MenuItem } from 'react-bootstrap' import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
import MaterialIcon from '@/shared/components/material-icon'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import Icon from '../../../../../../shared/components/icon' import Icon from '../../../../../../shared/components/icon'
type DownloadProps = { type DownloadProps = {
@ -17,15 +19,21 @@ function Download({
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<MenuItem <OLDropdownMenuItem
href={`/project/${projectId}/version/${version}/zip`} href={`/project/${projectId}/version/${version}/zip`}
download={`${projectId}_v${version}.zip`} download={`${projectId}_v${version}.zip`}
rel="noreferrer" rel="noreferrer"
onClick={closeDropdown} onClick={closeDropdown}
leadingIcon={
<BootstrapVersionSwitcher
bs3={<Icon type="cloud-download" fw />}
bs5={<MaterialIcon type="download" />}
/>
}
{...props} {...props}
> >
<Icon type="cloud-download" fw /> {t('history_download_this_version')} {t('history_download_this_version')}
</MenuItem> </OLDropdownMenuItem>
) )
} }

View file

@ -1,12 +1,14 @@
import Icon from '@/shared/components/icon' import Icon from '@/shared/components/icon'
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import { MenuItem } from 'react-bootstrap' import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { RestoreProjectModal } from '../../../diff-view/modals/restore-project-modal' import { RestoreProjectModal } from '../../../diff-view/modals/restore-project-modal'
import { useSplitTestContext } from '@/shared/context/split-test-context' import { useSplitTestContext } from '@/shared/context/split-test-context'
import { useRestoreProject } from '@/features/history/context/hooks/use-restore-project' import { useRestoreProject } from '@/features/history/context/hooks/use-restore-project'
import withErrorBoundary from '@/infrastructure/error-boundary' import withErrorBoundary from '@/infrastructure/error-boundary'
import { RestoreProjectErrorModal } from '../../../diff-view/modals/restore-project-error-modal' import { RestoreProjectErrorModal } from '../../../diff-view/modals/restore-project-error-modal'
import MaterialIcon from '@/shared/components/material-icon'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
type RestoreProjectProps = { type RestoreProjectProps = {
projectId: string projectId: string
@ -44,9 +46,18 @@ const RestoreProject = ({
return ( return (
<> <>
<MenuItem onClick={handleClick}> <OLDropdownMenuItem
<Icon type="undo" fw /> {t('restore_project_to_this_version')} as="button"
</MenuItem> leadingIcon={
<BootstrapVersionSwitcher
bs3={<Icon type="undo" fw />}
bs5={<MaterialIcon type="undo" />}
/>
}
onClick={handleClick}
>
{t('restore_project_to_this_version')}
</OLDropdownMenuItem>
<RestoreProjectModal <RestoreProjectModal
setShow={setShowModal} setShow={setShowModal}
show={showModal} show={showModal}

View file

@ -35,7 +35,7 @@ function HistoryVersionDetails({
// TODO: Sort out accessibility for this // TODO: Sort out accessibility for this
// eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions
<div <div
className={classnames('history-version-details clearfix', { className={classnames('history-version-details', {
'history-version-selected': 'history-version-selected':
selectionState === 'upperSelected' || selectionState === 'upperSelected' ||
selectionState === 'lowerSelected' || selectionState === 'lowerSelected' ||

View file

@ -22,6 +22,7 @@ import { CompareVersionDropdownContentAllHistory } from './dropdown/compare-vers
import FileRestoreChange from './file-restore-change' import FileRestoreChange from './file-restore-change'
import HistoryResyncChange from './history-resync-change' import HistoryResyncChange from './history-resync-change'
import ProjectRestoreChange from './project-restore-change' import ProjectRestoreChange from './project-restore-change'
import { bsVersion } from '@/features/utils/bootstrap-5'
type HistoryVersionProps = { type HistoryVersionProps = {
update: LoadedUpdate update: LoadedUpdate
@ -123,7 +124,10 @@ function HistoryVersion({
)} )}
{selectionState !== 'selected' && !faded ? ( {selectionState !== 'selected' && !faded ? (
<div data-testid="compare-icon-version" className="pull-right"> <div
data-testid="compare-icon-version"
className={bsVersion({ bs3: 'pull-right', bs5: 'float-end' })}
>
{selectionState !== 'withinSelected' ? ( {selectionState !== 'withinSelected' ? (
<CompareItems <CompareItems
updateRange={updateRange} updateRange={updateRange}

View file

@ -13,6 +13,7 @@ import { ItemSelectionState } from '../../utils/history-details'
import CompareVersionDropdown from './dropdown/compare-version-dropdown' import CompareVersionDropdown from './dropdown/compare-version-dropdown'
import { CompareVersionDropdownContentLabelsList } from './dropdown/compare-version-dropdown-content' import { CompareVersionDropdownContentLabelsList } from './dropdown/compare-version-dropdown-content'
import HistoryDropdownContent from '@/features/history/components/change-list/dropdown/history-dropdown-content' import HistoryDropdownContent from '@/features/history/components/change-list/dropdown/history-dropdown-content'
import { bsVersion } from '@/features/utils/bootstrap-5'
type LabelListItemProps = { type LabelListItemProps = {
version: Version version: Version
@ -96,7 +97,10 @@ function LabelListItem({
) : null} ) : null}
</HistoryDropdown> </HistoryDropdown>
{selectionState !== 'selected' ? ( {selectionState !== 'selected' ? (
<div data-testid="compare-icon-version" className="pull-right"> <div
data-testid="compare-icon-version"
className={bsVersion({ bs3: 'pull-right', bs5: 'float-end' })}
>
{selectionState !== 'withinSelected' ? ( {selectionState !== 'withinSelected' ? (
<CompareItems <CompareItems
updateRange={updateRange} updateRange={updateRange}

View file

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

View file

@ -1,9 +1,12 @@
import { useState } from 'react' import { forwardRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Modal } from 'react-bootstrap' import OLModal, {
import Icon from '../../../../shared/components/icon' OLModalBody,
import Tooltip from '../../../../shared/components/tooltip' OLModalFooter,
import AccessibleModal from '../../../../shared/components/accessible-modal' OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import ModalError from './modal-error' import ModalError from './modal-error'
import useAbortController from '../../../../shared/hooks/use-abort-controller' import useAbortController from '../../../../shared/hooks/use-abort-controller'
import useAsync from '../../../../shared/hooks/use-async' import useAsync from '../../../../shared/hooks/use-async'
@ -15,118 +18,127 @@ import { LoadedLabel } from '../../services/types/label'
import { debugConsole } from '@/utils/debugging' import { debugConsole } from '@/utils/debugging'
import { formatTimeBasedOnYear } from '@/features/utils/format-date' import { formatTimeBasedOnYear } from '@/features/utils/format-date'
import { useEditorContext } from '@/shared/context/editor-context' import { useEditorContext } from '@/shared/context/editor-context'
import Tag from '@/shared/components/tag' 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 = { type TagProps = {
label: LoadedLabel label: LoadedLabel
currentUserId: string currentUserId: string
} }
function ChangeTag({ label, currentUserId, ...props }: TagProps) { const ChangeTag = forwardRef<HTMLElement, TagProps>(
const { isProjectOwner } = useEditorContext() ({ label, currentUserId, ...props }: TagProps, ref) => {
const { isProjectOwner } = useEditorContext()
const { t } = useTranslation() const { t } = useTranslation()
const [showDeleteModal, setShowDeleteModal] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false)
const { projectId } = useHistoryContext() const { projectId } = useHistoryContext()
const { signal } = useAbortController() const { signal } = useAbortController()
const { removeUpdateLabel } = useAddOrRemoveLabels() const { removeUpdateLabel } = useAddOrRemoveLabels()
const { isLoading, isSuccess, isError, error, reset, runAsync } = useAsync() const { isLoading, isSuccess, isError, error, reset, runAsync } = useAsync()
const isPseudoCurrentStateLabel = isPseudoLabel(label) const isPseudoCurrentStateLabel = isPseudoLabel(label)
const isOwnedByCurrentUser = !isPseudoCurrentStateLabel const isOwnedByCurrentUser = !isPseudoCurrentStateLabel
? label.user_id === currentUserId ? label.user_id === currentUserId
: null : null
const showConfirmationModal = (e: React.MouseEvent) => { const showConfirmationModal = (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
setShowDeleteModal(true) setShowDeleteModal(true)
}
const handleModalExited = () => {
if (!isSuccess) return
if (!isPseudoCurrentStateLabel) {
removeUpdateLabel(label)
} }
reset() const handleModalExited = () => {
} if (!isSuccess) return
const localDeleteHandler = () => { if (!isPseudoCurrentStateLabel) {
runAsync(deleteLabel(projectId, label.id, signal)) removeUpdateLabel(label)
.then(() => setShowDeleteModal(false)) }
.catch(debugConsole.error)
}
const responseError = error as unknown as { reset()
response: Response
data?: {
message?: string
} }
}
const showCloseButton = Boolean( const localDeleteHandler = () => {
(isOwnedByCurrentUser || isProjectOwner) && !isPseudoCurrentStateLabel runAsync(deleteLabel(projectId, label.id, signal))
) .then(() => setShowDeleteModal(false))
.catch(debugConsole.error)
}
return ( const responseError = error as unknown as {
<> response: Response
<Tag data?: {
prepend={<Icon type="tag" fw />} message?: string
closeBtnProps={ }
showCloseButton }
? { 'aria-label': t('delete'), onClick: showConfirmationModal }
: undefined const showCloseButton = Boolean(
} (isOwnedByCurrentUser || isProjectOwner) && !isPseudoCurrentStateLabel
className="history-version-badge" )
data-testid="history-version-badge"
{...props} return (
> <>
{isPseudoCurrentStateLabel <OLTag
? t('history_label_project_current_state') ref={ref}
: label.comment} prepend={<OLTagIcon />}
</Tag> closeBtnProps={
{!isPseudoCurrentStateLabel && ( showCloseButton
<AccessibleModal ? { 'aria-label': t('delete'), onClick: showConfirmationModal }
show={showDeleteModal} : undefined
onExited={handleModalExited} }
onHide={() => setShowDeleteModal(false)} className="history-version-badge"
id="delete-history-label" data-testid="history-version-badge"
{...props}
> >
<Modal.Header> {isPseudoCurrentStateLabel
<Modal.Title>{t('history_delete_label')}</Modal.Title> ? t('history_label_project_current_state')
</Modal.Header> : label.comment}
<Modal.Body> </OLTag>
{isError && <ModalError error={responseError} />} {!isPseudoCurrentStateLabel && (
<p> <OLModal
{t('history_are_you_sure_delete_label')}&nbsp; show={showDeleteModal}
<strong>"{label.comment}"</strong>? onExited={handleModalExited}
</p> onHide={() => setShowDeleteModal(false)}
</Modal.Body> id="delete-history-label"
<Modal.Footer> >
<button <OLModalHeader>
type="button" <OLModalTitle>{t('history_delete_label')}</OLModalTitle>
className="btn btn-secondary" </OLModalHeader>
disabled={isLoading} <OLModalBody>
onClick={() => setShowDeleteModal(false)} {isError && <ModalError error={responseError} />}
> <p>
{t('cancel')} {t('history_are_you_sure_delete_label')}&nbsp;
</button> <strong>"{label.comment}"</strong>?
<button </p>
type="button" </OLModalBody>
className="btn btn-danger" <OLModalFooter>
disabled={isLoading} <OLButton
onClick={localDeleteHandler} variant="secondary"
> disabled={isLoading}
{isLoading onClick={() => setShowDeleteModal(false)}
? t('history_deleting_label') >
: t('history_delete_label')} {t('cancel')}
</button> </OLButton>
</Modal.Footer> <OLButton
</AccessibleModal> variant="danger"
)} disabled={isLoading}
</> isLoading={isLoading}
) onClick={localDeleteHandler}
} bs3Props={{
loading: isLoading
? t('history_deleting_label')
: t('history_delete_label'),
}}
>
{t('history_delete_label')}
</OLButton>
</OLModalFooter>
</OLModal>
)}
</>
)
}
)
ChangeTag.displayName = 'ChangeTag'
type LabelBadgesProps = { type LabelBadgesProps = {
showTooltip: boolean showTooltip: boolean
@ -145,13 +157,14 @@ function TagTooltip({ label, currentUserId, showTooltip }: LabelBadgesProps) {
? currentLabelData.user_display_name ? currentLabelData.user_display_name
: t('anonymous') : t('anonymous')
return showTooltip && !isPseudoCurrentStateLabel ? ( return !isPseudoCurrentStateLabel ? (
<Tooltip <OLTooltip
description={ description={
<div className="history-version-label-tooltip"> <div className="history-version-label-tooltip">
<div className="history-version-label-tooltip-row"> <div className="history-version-label-tooltip-row">
<b className="history-version-label-tooltip-row-comment"> <b className="history-version-label-tooltip-row-comment">
<Icon type="tag" fw /> <OLTagIcon />
&nbsp;
{label.comment} {label.comment}
</b> </b>
</div> </div>
@ -165,9 +178,10 @@ function TagTooltip({ label, currentUserId, showTooltip }: LabelBadgesProps) {
} }
id={label.id} id={label.id}
overlayProps={{ placement: 'left' }} overlayProps={{ placement: 'left' }}
hidden={!showTooltip}
> >
<ChangeTag label={label} currentUserId={currentUserId} /> <ChangeTag label={label} currentUserId={currentUserId} />
</Tooltip> </OLTooltip>
) : ( ) : (
<ChangeTag label={label} currentUserId={currentUserId} /> <ChangeTag label={label} currentUserId={currentUserId} />
) )

View file

@ -1,4 +1,10 @@
import { Button, Modal } from 'react-bootstrap' 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' import { useTranslation } from 'react-i18next'
export function RestoreProjectErrorModal({ export function RestoreProjectErrorModal({
@ -9,26 +15,22 @@ export function RestoreProjectErrorModal({
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<Modal show onHide={resetErrorBoundary}> <OLModal show onHide={resetErrorBoundary}>
<Modal.Header closeButton> <OLModalHeader closeButton>
<Modal.Title> <OLModalTitle>
{t('an_error_occured_while_restoring_project')} {t('an_error_occured_while_restoring_project')}
</Modal.Title> </OLModalTitle>
</Modal.Header> </OLModalHeader>
<Modal.Body> <OLModalBody>
{t( {t(
'there_was_a_problem_restoring_the_project_please_try_again_in_a_few_moments_or_contact_us' 'there_was_a_problem_restoring_the_project_please_try_again_in_a_few_moments_or_contact_us'
)} )}
</Modal.Body> </OLModalBody>
<Modal.Footer> <OLModalFooter>
<Button <OLButton variant="secondary" onClick={resetErrorBoundary}>
bsStyle={null}
className="btn-secondary"
onClick={resetErrorBoundary}
>
{t('close')} {t('close')}
</Button> </OLButton>
</Modal.Footer> </OLModalFooter>
</Modal> </OLModal>
) )
} }

View file

@ -1,7 +1,12 @@
import AccessibleModal from '@/shared/components/accessible-modal' import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import { formatDate } from '@/utils/dates' import { formatDate } from '@/utils/dates'
import { useCallback } from 'react' import { useCallback } from 'react'
import { Button, Modal } from 'react-bootstrap' import OLButton from '@/features/ui/components/ol/ol-button'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
type RestoreProjectModalProps = { type RestoreProjectModalProps = {
@ -26,35 +31,31 @@ export const RestoreProjectModal = ({
}, [setShow]) }, [setShow])
return ( return (
<AccessibleModal onHide={() => setShow(false)} show={show}> <OLModal onHide={() => setShow(false)} show={show}>
<Modal.Header> <OLModalHeader>
<Modal.Title>{t('restore_this_version')}</Modal.Title> <OLModalTitle>{t('restore_this_version')}</OLModalTitle>
</Modal.Header> </OLModalHeader>
<Modal.Body> <OLModalBody>
<p> <p>
{t('your_current_project_will_revert_to_the_version_from_time', { {t('your_current_project_will_revert_to_the_version_from_time', {
timestamp: formatDate(endTimestamp), timestamp: formatDate(endTimestamp),
})} })}
</p> </p>
</Modal.Body> </OLModalBody>
<Modal.Footer> <OLModalFooter>
<Button <OLButton variant="secondary" onClick={onCancel} disabled={isRestoring}>
className="btn btn-secondary"
bsStyle={null}
onClick={onCancel}
disabled={isRestoring}
>
{t('cancel')} {t('cancel')}
</Button> </OLButton>
<Button <OLButton
className="btn btn-primary" variant="primary"
bsStyle={null}
onClick={onRestore} onClick={onRestore}
disabled={isRestoring} disabled={isRestoring}
isLoading={isRestoring}
bs3Props={{ loading: isRestoring ? t('restoring') : t('restore') }}
> >
{isRestoring ? t('restoring') : t('restore')} {t('restore')}
</Button> </OLButton>
</Modal.Footer> </OLModalFooter>
</AccessibleModal> </OLModal>
) )
} }

View file

@ -1,6 +1,6 @@
import { memo, useCallback, useState } from 'react' import { memo, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import OlDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item' import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
import CloneProjectModal from '../../../../../clone-project-modal/components/clone-project-modal' import CloneProjectModal from '../../../../../clone-project-modal/components/clone-project-modal'
import useIsMounted from '../../../../../../shared/hooks/use-is-mounted' import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
import { useProjectListContext } from '../../../../context/project-list-context' import { useProjectListContext } from '../../../../context/project-list-context'
@ -64,9 +64,9 @@ function CopyProjectMenuItem() {
return ( return (
<> <>
<OlDropdownMenuItem onClick={handleOpenModal} as="button" tabIndex={-1}> <OLDropdownMenuItem onClick={handleOpenModal} as="button" tabIndex={-1}>
{t('make_a_copy')} {t('make_a_copy')}
</OlDropdownMenuItem> </OLDropdownMenuItem>
<CloneProjectModal <CloneProjectModal
show={showModal} show={showModal}
handleHide={handleCloseModal} handleHide={handleCloseModal}

View file

@ -2,7 +2,7 @@ import { memo, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useProjectListContext } from '../../../../context/project-list-context' import { useProjectListContext } from '../../../../context/project-list-context'
import useIsMounted from '../../../../../../shared/hooks/use-is-mounted' import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
import OlDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item' import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
import RenameProjectModal from '../../../modals/rename-project-modal' import RenameProjectModal from '../../../modals/rename-project-modal'
function RenameProjectMenuItem() { function RenameProjectMenuItem() {
@ -34,9 +34,9 @@ function RenameProjectMenuItem() {
return ( return (
<> <>
<OlDropdownMenuItem onClick={handleOpenModal} as="button" tabIndex={-1}> <OLDropdownMenuItem onClick={handleOpenModal} as="button" tabIndex={-1}>
{t('rename')} {t('rename')}
</OlDropdownMenuItem> </OLDropdownMenuItem>
<RenameProjectModal <RenameProjectModal
handleCloseModal={handleCloseModal} handleCloseModal={handleCloseModal}
showModal={showModal} showModal={showModal}

View file

@ -2,7 +2,7 @@ import { forwardRef, SyntheticEvent } from 'react'
import classnames from 'classnames' import classnames from 'classnames'
import RootCloseWrapper from 'react-overlays/lib/RootCloseWrapper' import RootCloseWrapper from 'react-overlays/lib/RootCloseWrapper'
import { DropdownProps } from 'react-bootstrap' import { DropdownProps } from 'react-bootstrap'
import { MergeAndOverride } from '../../../../../types/utils' import { MergeAndOverride } from '../../../../../../types/utils'
type DropdownMenuWithRefProps = MergeAndOverride< type DropdownMenuWithRefProps = MergeAndOverride<
Pick<DropdownProps, 'bsClass' | 'open' | 'pullRight' | 'onClose'>, Pick<DropdownProps, 'bsClass' | 'open' | 'pullRight' | 'onClose'>,

View file

@ -1,8 +1,8 @@
import { forwardRef } from 'react' import { forwardRef } from 'react'
import Tooltip from '../tooltip' import Tooltip from '../../../../shared/components/tooltip'
import classnames from 'classnames' import classnames from 'classnames'
import { DropdownProps } from 'react-bootstrap' import { DropdownProps } from 'react-bootstrap'
import { MergeAndOverride } from '../../../../../types/utils' import { MergeAndOverride } from '../../../../../../types/utils'
type CustomToggleProps = MergeAndOverride< type CustomToggleProps = MergeAndOverride<
Pick<DropdownProps, 'bsClass' | 'open'>, Pick<DropdownProps, 'bsClass' | 'open'>,

View file

@ -0,0 +1,52 @@
import { ReactNode, forwardRef } from 'react'
import { BsPrefixRefForwardingComponent } from 'react-bootstrap-5/helpers'
import type { DropdownToggleProps } from '@/features/ui/components/types/dropdown-menu-props'
import {
DropdownToggle as BS5DropdownToggle,
OverlayTrigger,
OverlayTriggerProps,
Tooltip,
} from 'react-bootstrap-5'
import type { MergeAndOverride } from '../../../../../../types/utils'
type DropdownToggleWithTooltipProps = MergeAndOverride<
DropdownToggleProps,
{
children: ReactNode
overlayTriggerProps?: Omit<OverlayTriggerProps, 'overlay' | 'children'>
toolTipDescription: string
tooltipProps?: Omit<React.ComponentProps<typeof Tooltip>, 'children'>
'aria-label'?: string
}
>
const DropdownToggleWithTooltip = forwardRef<
BsPrefixRefForwardingComponent<'button', DropdownToggleProps>,
DropdownToggleWithTooltipProps
>(
(
{
children,
toolTipDescription,
overlayTriggerProps,
tooltipProps,
id,
...toggleProps
},
ref
) => {
return (
<OverlayTrigger
overlay={<Tooltip {...tooltipProps}>{toolTipDescription}</Tooltip>}
{...overlayTriggerProps}
>
<BS5DropdownToggle {...toggleProps} ref={ref}>
{children}
</BS5DropdownToggle>
</OverlayTrigger>
)
}
)
DropdownToggleWithTooltip.displayName = 'DropdownToggleWithTooltip'
export default DropdownToggleWithTooltip

View file

@ -3,6 +3,7 @@ import { Badge, BadgeProps } from 'react-bootstrap-5'
import MaterialIcon from '@/shared/components/material-icon' import MaterialIcon from '@/shared/components/material-icon'
import { MergeAndOverride } from '../../../../../../types/utils' import { MergeAndOverride } from '../../../../../../types/utils'
import classnames from 'classnames' import classnames from 'classnames'
import { forwardRef } from 'react'
type TagProps = MergeAndOverride< type TagProps = MergeAndOverride<
BadgeProps, BadgeProps,
@ -13,50 +14,55 @@ type TagProps = MergeAndOverride<
} }
> >
function Tag({ const Tag = forwardRef<HTMLElement, TagProps>(
prepend, (
children, { prepend, children, contentProps, closeBtnProps, className, ...rest },
contentProps, ref
closeBtnProps, ) => {
className, const { t } = useTranslation()
...rest
}: TagProps) {
const { t } = useTranslation()
const content = ( const content = (
<> <>
{prepend && <span className="badge-prepend">{prepend}</span>} {prepend && <span className="badge-prepend">{prepend}</span>}
<span className="badge-content">{children}</span> <span className="badge-content">{children}</span>
</> </>
) )
return ( return (
<Badge bg="light" className={classnames('badge-tag', className)} {...rest}> <Badge
{contentProps?.onClick ? ( ref={ref}
<button bg="light"
type="button" className={classnames('badge-tag', className)}
className="badge-tag-content badge-tag-content-btn" {...rest}
{...contentProps} >
> {contentProps?.onClick ? (
{content} <button
</button> type="button"
) : ( className="badge-tag-content badge-tag-content-btn"
<span className="badge-tag-content" {...contentProps}> {...contentProps}
{content} >
</span> {content}
)} </button>
{closeBtnProps && ( ) : (
<button <span className="badge-tag-content" {...contentProps}>
type="button" {content}
className="badge-close" </span>
aria-label={t('remove_tag', { tagName: children })} )}
{...closeBtnProps} {closeBtnProps && (
> <button
<MaterialIcon className="badge-close-icon" type="close" /> type="button"
</button> className="badge-close"
)} aria-label={t('remove_tag', { tagName: children })}
</Badge> {...closeBtnProps}
) >
} <MaterialIcon className="badge-close-icon" type="close" />
</button>
)}
</Badge>
)
}
)
Tag.displayName = 'Tag'
export default Tag export default Tag

View file

@ -0,0 +1,12 @@
import Icon from '@/shared/components/icon'
import MaterialIcon from '@/shared/components/material-icon'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
export default function OLTagIcon() {
return (
<BootstrapVersionSwitcher
bs3={<Icon type="tag" fw />}
bs5={<MaterialIcon type="sell" />}
/>
)
}

View file

@ -3,16 +3,26 @@ import { DropdownItem } from '@/features/ui/components/bootstrap-5/dropdown-menu
import { DropdownItemProps } from '@/features/ui/components/types/dropdown-menu-props' import { DropdownItemProps } from '@/features/ui/components/types/dropdown-menu-props'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher' import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
type OlDropdownMenuItemProps = DropdownItemProps & { type OLDropdownMenuItemProps = DropdownItemProps & {
bs3Props?: MenuItemProps bs3Props?: MenuItemProps
} }
function OlDropdownMenuItem(props: OlDropdownMenuItemProps) { function OLDropdownMenuItem(props: OLDropdownMenuItemProps) {
const { bs3Props, ...rest } = props const { bs3Props, ...rest } = props
const bs3MenuItemProps: MenuItemProps = { const bs3MenuItemProps: MenuItemProps = {
children: rest.children, children: rest.leadingIcon ? (
<>
{rest.leadingIcon}
&nbsp;
{rest.children}
</>
) : (
rest.children
),
onClick: rest.onClick, onClick: rest.onClick,
href: rest.href,
download: rest.download,
...bs3Props, ...bs3Props,
} }
@ -24,4 +34,4 @@ function OlDropdownMenuItem(props: OlDropdownMenuItemProps) {
) )
} }
export default OlDropdownMenuItem export default OLDropdownMenuItem

View file

@ -47,6 +47,7 @@ export default function OLModal({ children, ...props }: OLModalProps) {
bsSize: bs5Props.size, bsSize: bs5Props.size,
show: bs5Props.show, show: bs5Props.show,
onHide: bs5Props.onHide, onHide: bs5Props.onHide,
onExited: bs5Props.onExited,
backdrop: bs5Props.backdrop, backdrop: bs5Props.backdrop,
animation: bs5Props.animation, animation: bs5Props.animation,
id: bs5Props.id, id: bs5Props.id,
@ -86,10 +87,15 @@ export function OLModalTitle({ children, ...props }: OLModalTitleProps) {
const bs3ModalProps: BS3ModalTitleProps = { const bs3ModalProps: BS3ModalTitleProps = {
componentClass: bs5Props.as, componentClass: bs5Props.as,
} }
return ( return (
<BootstrapVersionSwitcher <BootstrapVersionSwitcher
bs3={<BS3Modal.Title {...bs3ModalProps}>{children}</BS3Modal.Title>} bs3={<BS3Modal.Title {...bs3ModalProps}>{children}</BS3Modal.Title>}
bs5={<BS5Modal.Title {...bs5Props}>{children}</BS5Modal.Title>} bs5={
<BS5Modal.Title as="h2" {...bs5Props}>
{children}
</BS5Modal.Title>
}
/> />
) )
} }

View file

@ -40,8 +40,9 @@ function OLOverlay(props: OLOverlayProps) {
> >
for (const placement of bs3PlacementOptions) { for (const placement of bs3PlacementOptions) {
if (placement === bs5Props.placement) { // BS5 has more placement options than BS3, such as "left-start", so these are mapped to "left" etc.
bs3OverlayProps.placement = bs5Props.placement if (bs5Props.placement.startsWith(placement)) {
bs3OverlayProps.placement = placement
break break
} }
} }

View file

@ -6,7 +6,7 @@ import {
} from 'react-bootstrap' } from 'react-bootstrap'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher' import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
type OLPopoverProps = PopoverProps & { type OLPopoverProps = Omit<PopoverProps, 'title'> & {
title?: React.ReactNode title?: React.ReactNode
bs3Props?: BS3PopoverProps bs3Props?: BS3PopoverProps
} }

View file

@ -0,0 +1,31 @@
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import Icon from '@/shared/components/icon'
import { Spinner } from 'react-bootstrap-5'
import classNames from 'classnames'
export type OLSpinnerSize = 'sm' | 'lg'
function OLSpinner({ size = 'sm' }: { size: OLSpinnerSize }) {
return (
<BootstrapVersionSwitcher
bs3={
<Icon
type="refresh"
fw
spin
className={classNames({ 'fa-2x': size === 'lg' })}
/>
}
bs5={
<Spinner
size={size === 'sm' ? 'sm' : undefined}
animation="border"
aria-hidden="true"
role="status"
/>
}
/>
)
}
export default OLSpinner

View file

@ -1,12 +1,14 @@
import Tag from '@/features/ui/components/bootstrap-5/tag' import Tag from '@/features/ui/components/bootstrap-5/tag'
import BS3Tag from '@/shared/components/tag' import BS3Tag from '@/shared/components/tag'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher' import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import { forwardRef } from 'react'
import { getAriaAndDataProps } from '@/features/utils/bootstrap-5'
type OLTagProps = React.ComponentProps<typeof Tag> & { type OLTagProps = React.ComponentProps<typeof Tag> & {
bs3Props?: React.ComponentProps<typeof BS3Tag> bs3Props?: React.ComponentProps<typeof BS3Tag>
} }
function OLTag(props: OLTagProps) { const OLTag = forwardRef<HTMLElement, OLTagProps>((props: OLTagProps, ref) => {
const { bs3Props, ...rest } = props const { bs3Props, ...rest } = props
const bs3TagProps: React.ComponentProps<typeof BS3Tag> = { const bs3TagProps: React.ComponentProps<typeof BS3Tag> = {
@ -14,15 +16,23 @@ function OLTag(props: OLTagProps) {
prepend: rest.prepend, prepend: rest.prepend,
closeBtnProps: rest.closeBtnProps, closeBtnProps: rest.closeBtnProps,
className: rest.className, className: rest.className,
onClick: rest.onClick,
onFocus: rest.onFocus,
onBlur: rest.onBlur,
onMouseOver: rest.onMouseOver,
onMouseOut: rest.onMouseOut,
...getAriaAndDataProps(rest),
...bs3Props, ...bs3Props,
} }
return ( return (
<BootstrapVersionSwitcher <BootstrapVersionSwitcher
bs3={<BS3Tag {...bs3TagProps} />} bs3={<BS3Tag {...bs3TagProps} />}
bs5={<Tag {...rest} />} bs5={<Tag ref={ref} {...rest} />}
/> />
) )
} })
OLTag.displayName = 'OLTag'
export default OLTag export default OLTag

View file

@ -42,6 +42,8 @@ export type DropdownItemProps = PropsWithChildren<{
role?: string role?: string
tabIndex?: number tabIndex?: number
target?: string target?: string
download?: boolean | string
rel?: string
}> }>
export type DropdownToggleProps = PropsWithChildren<{ export type DropdownToggleProps = PropsWithChildren<{
@ -53,6 +55,7 @@ export type DropdownToggleProps = PropsWithChildren<{
variant?: SplitButtonVariants variant?: SplitButtonVariants
as?: ElementType as?: ElementType
size?: 'sm' | 'lg' | undefined size?: 'sm' | 'lg' | undefined
'aria-label'?: string
}> }>
export type DropdownMenuProps = PropsWithChildren<{ export type DropdownMenuProps = PropsWithChildren<{

View file

@ -1,8 +1,9 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Icon from './icon'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher' import OLSpinner, {
import { Spinner } from 'react-bootstrap-5' OLSpinnerSize,
} from '@/features/ui/components/ol/ol-spinner'
import { isBootstrap5 } from '@/features/utils/bootstrap-5'
import { setTimeout } from '@/utils/window' import { setTimeout } from '@/utils/window'
import classNames from 'classnames' import classNames from 'classnames'
@ -10,12 +11,14 @@ function LoadingSpinner({
align, align,
delay = 0, delay = 0,
loadingText, loadingText,
size, size = 'sm',
className,
}: { }: {
align?: 'left' | 'center' align?: 'left' | 'center'
delay?: 0 | 500 // 500 is our standard delay delay?: 0 | 500 // 500 is our standard delay
loadingText?: string loadingText?: string
size?: 'sm' size?: OLSpinnerSize
className?: string
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
@ -35,32 +38,19 @@ function LoadingSpinner({
return null return null
} }
const alignmentClass = const extraClasses = isBootstrap5()
align === 'left' ? 'align-items-start' : 'align-items-center' ? [
'd-inline-flex',
align === 'left' ? 'align-items-start' : 'align-items-center',
]
: null
return ( return (
<BootstrapVersionSwitcher <div className={classNames('loading', className, extraClasses)}>
bs3={ <OLSpinner size={size} />
<div className="loading"> &nbsp;
<Icon type="refresh" fw spin /> {loadingText || t('loading')}
&nbsp; </div>
{loadingText || t('loading')}
</div>
}
bs5={
<div className={classNames(`d-flex ${alignmentClass}`)}>
<Spinner
animation="border"
aria-hidden="true"
role="status"
className="align-self-center"
size={size}
/>
&nbsp;
{loadingText || t('loading')}
</div>
}
/>
) )
} }
@ -70,14 +60,16 @@ export function FullSizeLoadingSpinner({
delay = 0, delay = 0,
minHeight, minHeight,
loadingText, loadingText,
size = 'sm',
}: { }: {
delay?: 0 | 500 delay?: 0 | 500
minHeight?: string minHeight?: string
loadingText?: string loadingText?: string
size?: OLSpinnerSize
}) { }) {
return ( return (
<div className="full-size-loading-spinner-container" style={{ minHeight }}> <div className="full-size-loading-spinner-container" style={{ minHeight }}>
<LoadingSpinner loadingText={loadingText} delay={delay} /> <LoadingSpinner size={size} loadingText={loadingText} delay={delay} />
</div> </div>
) )
} }

View file

@ -18,7 +18,9 @@ function SystemMessage({ id, children }: SystemMessageProps) {
return ( return (
<li className="system-message"> <li className="system-message">
{id !== 'protected' ? <Close onDismiss={() => setHidden(true)} /> : null} {id !== 'protected' ? (
<Close onDismiss={() => setHidden(true)} variant="dark" />
) : null}
{children} {children}
</li> </li>
) )

View file

@ -5,6 +5,7 @@ import {
DropdownHeader, DropdownHeader,
} from '@/features/ui/components/bootstrap-5/dropdown-menu' } from '@/features/ui/components/bootstrap-5/dropdown-menu'
import type { Meta } from '@storybook/react' import type { Meta } from '@storybook/react'
import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
type Args = React.ComponentProps<typeof DropdownMenu> type Args = React.ComponentProps<typeof DropdownMenu>
@ -141,42 +142,52 @@ export const Icon = (args: Args) => {
return ( return (
<DropdownMenu show> <DropdownMenu show>
<li> <li>
<DropdownItem <OLDropdownMenuItem
disabled={args.disabled} disabled={args.disabled}
eventKey="1" eventKey="1"
href="#/action-1" href="#/action-1"
leadingIcon="view_column_2" leadingIcon="view_column_2"
> >
Editor & PDF Editor & PDF
</DropdownItem> </OLDropdownMenuItem>
</li> </li>
<li> <li>
<DropdownItem <OLDropdownMenuItem
active active
eventKey="2" eventKey="2"
href="#/action-2" href="#/action-2"
leadingIcon="terminal" leadingIcon="terminal"
> >
Editor only Editor only
</DropdownItem> </OLDropdownMenuItem>
</li> </li>
<li> <li>
<DropdownItem <OLDropdownMenuItem
eventKey="2" eventKey="3"
href="#/action-2" href="#/action-3"
leadingIcon="picture_as_pdf" leadingIcon="picture_as_pdf"
> >
PDF only PDF only
</DropdownItem> </OLDropdownMenuItem>
</li> </li>
<li> <li>
<DropdownItem <OLDropdownMenuItem
eventKey="2" eventKey="4"
href="#/action-2" href="#/action-4"
leadingIcon="select_window" leadingIcon="select_window"
> >
PDF in separate tab PDF in separate tab
</DropdownItem> </OLDropdownMenuItem>
</li>
<li>
<OLDropdownMenuItem
eventKey="5"
href="#/action-5"
leadingIcon="align_space_even"
className="dropdown-item-material-icon-small"
>
Small icon
</OLDropdownMenuItem>
</li> </li>
</DropdownMenu> </DropdownMenu>
) )

View file

@ -1,4 +1,4 @@
import Icon from '@/shared/components/icon' import OLTagIcon from '@/features/ui/components/ol/icons/ol-tag-icon'
import BS3Tag from '@/shared/components/tag' import BS3Tag from '@/shared/components/tag'
import type { Meta, StoryObj } from '@storybook/react' import type { Meta, StoryObj } from '@storybook/react'
@ -47,7 +47,7 @@ export const TagPrepend: Story = {
render: args => { render: args => {
return ( return (
<div className="small"> <div className="small">
<BS3Tag prepend={<Icon type="tag" fw />} {...args} /> <BS3Tag prepend={<OLTagIcon />} {...args} />
</div> </div>
) )
}, },
@ -58,7 +58,7 @@ export const TagWithCloseButton: Story = {
return ( return (
<div className="small"> <div className="small">
<BS3Tag <BS3Tag
prepend={<Icon type="tag" fw />} prepend={<OLTagIcon />}
closeBtnProps={{ closeBtnProps={{
onClick: () => alert('Close triggered!'), onClick: () => alert('Close triggered!'),
}} }}

View file

@ -1,4 +1,4 @@
import Icon from '@/shared/components/icon' import OLTagIcon from '@/features/ui/components/ol/icons/ol-tag-icon'
import Tag from '@/features/ui/components/bootstrap-5/tag' import Tag from '@/features/ui/components/bootstrap-5/tag'
import type { Meta, StoryObj } from '@storybook/react' import type { Meta, StoryObj } from '@storybook/react'
@ -41,7 +41,7 @@ export const TagDefault: Story = {
export const TagPrepend: Story = { export const TagPrepend: Story = {
render: args => { render: args => {
return <Tag prepend={<Icon type="tag" fw />} {...args} /> return <Tag prepend={<OLTagIcon />} {...args} />
}, },
} }
@ -49,7 +49,7 @@ export const TagWithCloseButton: Story = {
render: args => { render: args => {
return ( return (
<Tag <Tag
prepend={<Icon type="tag" fw />} prepend={<OLTagIcon />}
closeBtnProps={{ closeBtnProps={{
onClick: () => alert('Close triggered!'), onClick: () => alert('Close triggered!'),
}} }}
@ -63,7 +63,7 @@ export const TagWithContentButtonAndCloseButton: Story = {
render: args => { render: args => {
return ( return (
<Tag <Tag
prepend={<Icon type="tag" fw />} prepend={<OLTagIcon />}
contentProps={{ contentProps={{
onClick: () => alert('Content button clicked!'), onClick: () => alert('Content button clicked!'),
}} }}

View file

@ -44,9 +44,14 @@ history-root {
.doc-container { .doc-container {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
display: flex;
} }
} }
.doc-container .loading {
margin: 10rem auto auto;
}
.change-list { .change-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -102,6 +107,7 @@ history-root {
} }
.history-version-details { .history-version-details {
display: flow-root;
padding-top: 8px; padding-top: 8px;
padding-bottom: 8px; padding-bottom: 8px;
position: relative; position: relative;
@ -240,20 +246,15 @@ history-root {
} }
.loading { .loading {
padding-top: 10rem;
font-family: @font-family-serif; font-family: @font-family-serif;
text-align: center;
} }
& > .loading { .history-all-versions-loading {
flex: 1;
}
.history-all-versions-scroller .loading {
position: sticky; position: sticky;
bottom: 0; bottom: 0;
padding: @line-height-computed / 2 0; padding: @line-height-computed / 2 0;
background-color: @gray-lightest; background-color: @gray-lightest;
text-align: center;
} }
.history-version-saved-by { .history-version-saved-by {
@ -271,8 +272,11 @@ history-root {
.history-compare-btn, .history-compare-btn,
.history-version-dropdown-menu-btn { .history-version-dropdown-menu-btn {
.reset-button;
@size: 30px; @size: 30px;
padding: 0; padding: 0;
border-radius: @btn-border-radius-large;
width: @size; width: @size;
height: @size; height: @size;
line-height: 1; line-height: 1;

View file

@ -45,6 +45,20 @@
} }
} }
@mixin action-button {
font-size: 0;
line-height: 1;
border-radius: 50%;
color: var(--content-primary);
background-color: transparent;
&:hover,
&:active,
&[aria-expanded='true'] {
background-color: rgb($neutral-90, 0.08);
}
}
@mixin reset-button() { @mixin reset-button() {
padding: 0; padding: 0;
cursor: pointer; cursor: pointer;
@ -104,3 +118,28 @@
transparent transparent
); );
} }
@mixin mask-image($gradient) {
// mask-image isn't supported without the -webkit prefix on all browsers we support yet
-webkit-mask-image: $gradient;
mask-image: $gradient;
}
@mixin premium-background {
background-image: var(--premium-gradient);
}
@mixin premium-text {
@include premium-background;
background-clip: text;
-webkit-text-fill-color: transparent;
color: var(--white); // Fallback
background-color: var(--blue-70); // Fallback
}
@mixin dark-bg {
--link-color: var(--link-color-dark);
--link-hover-color: var(--link-hover-color-dark);
--link-visited-color: var(--link-visited-color-dark);
}

View file

@ -5,6 +5,9 @@
--link-color: var(--link-ui); --link-color: var(--link-ui);
--link-hover-color: var(--link-ui-hover); --link-hover-color: var(--link-ui-hover);
--link-visited-color: var(--link-ui-visited); --link-visited-color: var(--link-ui-visited);
--link-color-dark: var(--link-ui-dark);
--link-hover-color-dark: var(--link-ui-hover-dark);
--link-visited-color-dark: var(--link-ui-visited-dark);
} }
a { a {

View file

@ -74,10 +74,6 @@ samp {
list-style-image: url('../../../../public/img/fa-check-green.svg'); list-style-image: url('../../../../public/img/fa-check-green.svg');
} }
.text-center {
text-align: center;
}
.text-muted { .text-muted {
color: var(--content-disabled) !important; color: var(--content-disabled) !important;
} }

View file

@ -29,3 +29,4 @@
@import 'pagination'; @import 'pagination';
@import 'loading-spinner'; @import 'loading-spinner';
@import 'error-boundary'; @import 'error-boundary';
@import 'close-button';

View file

@ -16,6 +16,10 @@ $max-width: 160px;
margin-right: var(--spacing-02); margin-right: var(--spacing-02);
display: flex; display: flex;
align-items: center; align-items: center;
.material-symbols {
font-size: inherit;
}
} }
.badge-close { .badge-close {

View file

@ -164,14 +164,14 @@
.loading-spinner-small { .loading-spinner-small {
border-width: 0.2em; border-width: 0.2em;
height: 20px; height: 1.25rem;
width: 20px; width: 1.25rem;
} }
.loading-spinner-large { .loading-spinner-large {
border-width: 0.2em; border-width: 0.2em;
height: 24px; height: 1.5rem;
width: 24px; width: 1.5rem;
} }
} }
@ -187,11 +187,11 @@
justify-content: center; justify-content: center;
.icon-small { .icon-small {
font-size: 20px; font-size: 1.25rem;
} }
.icon-large { .icon-large {
font-size: 24px; font-size: 1.5rem;
} }
.spinner { .spinner {

View file

@ -0,0 +1,10 @@
// This is our own implementation because the Bootstrap close button requires more customization than is worthwhile
.close {
@include reset-button;
color: var(--content-primary);
&.dark {
color: var(--content-primary-dark);
}
}

View file

@ -167,3 +167,15 @@
.dropdown-item-highlighted { .dropdown-item-highlighted {
background-color: var(--bg-light-secondary); background-color: var(--bg-light-secondary);
} }
.dropdown-item-material-icon-small {
.material-symbols,
&.material-symbols {
font-size: var(--bs-body-font-size);
// Centre the symbol in a 20px-by-20px box
width: 20px;
line-height: 20px;
text-align: center;
}
}

View file

@ -1,3 +1,17 @@
.loading {
.spinner-border-sm,
.spinner-border {
// Ensure the thickness of the spinner is independent of the font size of its container
font-size: var(--font-size-03);
}
// Adjust the small spinner to be 25% larger than Bootstrap's default in each dimension
.spinner-border-sm {
--bs-spinner-width: 1.25rem;
--bs-spinner-height: 1.25rem;
}
}
.full-size-loading-spinner-container { .full-size-loading-spinner-container {
width: 100%; width: 100%;
height: 100%; height: 100%;

View file

@ -1,5 +1,6 @@
.popover { .popover {
@include shadow-md; @include shadow-md;
@include dark-bg;
line-height: var(--line-height-02); line-height: var(--line-height-02);
} }

View file

@ -17,9 +17,3 @@
} }
} }
} }
.system-message .close {
@include reset-button;
color: var(--content-primary-dark);
}

View file

@ -15,6 +15,7 @@
@import 'editor/figure-modal'; @import 'editor/figure-modal';
@import 'editor/review-panel'; @import 'editor/review-panel';
@import 'editor/chat'; @import 'editor/chat';
@import 'editor/history';
@import 'subscription'; @import 'subscription';
@import 'editor/pdf'; @import 'editor/pdf';
@import 'editor/compile-button'; @import 'editor/compile-button';

View file

@ -0,0 +1,383 @@
history-root {
height: 100%;
display: block;
}
// Adding !important to override the styling of overlays and popovers
.history-popover .popover-arrow {
top: 20px !important;
transform: unset !important;
}
.history-react {
--history-change-list-padding: var(--spacing-06);
display: flex;
height: 100%;
background-color: var(--bg-light-primary);
.history-header {
@include body-sm;
height: 40px;
background-color: var(--bg-dark-secondary);
color: var(--content-primary-dark);
display: flex;
flex-direction: column;
justify-content: center;
box-sizing: border-box;
}
.doc-panel {
flex: 1;
display: flex;
flex-direction: column;
.toolbar-container {
border-bottom: 1px solid var(--border-divider-dark);
padding: 0 var(--spacing-04);
}
.doc-container {
flex: 1;
overflow-y: auto;
display: flex;
}
}
.doc-container .loading {
margin: 10rem auto auto;
}
.change-list {
@include body-sm;
display: flex;
flex-direction: column;
width: 320px;
border-left: 1px solid var(--border-divider-dark);
box-sizing: content-box;
}
.toggle-switch-label {
flex: 1;
span {
display: block;
}
}
.history-version-list-container {
flex: 1;
overflow-y: auto;
}
.history-all-versions-scroller {
overflow-y: auto;
height: 100%;
}
.history-all-versions-container {
position: relative;
}
.history-versions-bottom {
position: absolute;
height: 8em;
bottom: 0;
}
.history-toggle-switch-container,
.history-version-day,
.history-version-details {
padding: 0 var(--history-change-list-padding);
}
.history-version-day {
background-color: white;
position: sticky;
z-index: 1;
top: 0;
display: block;
padding-top: var(--spacing-05);
padding-bottom: var(--spacing-02);
line-height: var(--line-height-02);
}
.history-version-details {
display: flow-root;
padding-top: var(--spacing-04);
padding-bottom: var(--spacing-04);
position: relative;
&.history-version-selectable {
cursor: pointer;
&:hover {
background-color: var(--bg-light-secondary);
}
}
&.history-version-selected {
background-color: var(--bg-accent-03);
border-left: var(--spacing-02) solid var(--green-50);
padding-left: calc(
var(--history-change-list-padding) - var(--spacing-02)
);
}
&.history-version-selected.history-version-selectable:hover {
background-color: rgb($green-70, 16%);
border-left: var(--spacing-02) solid var(--green-50);
}
&.history-version-within-selected {
background-color: var(--bg-light-secondary);
border-left: var(--spacing-02) solid var(--green-50);
}
&.history-version-within-selected:hover {
background-color: rgb($neutral-90, 8%);
}
}
.version-element-within-selected {
background-color: var(--bg-light-secondary);
border-left: var(--spacing-02) solid var(--green-50);
}
.version-element-selected {
background-color: var(--bg-accent-03);
border-left: var(--spacing-02) solid var(--green-50);
}
.history-version-metadata-time {
display: block;
margin-bottom: var(--spacing-02);
color: var(--content-primary);
&:last-child {
margin-bottom: initial;
}
}
.history-version-metadata-users,
.history-version-changes {
margin: 0;
padding: 0;
list-style: none;
}
.history-version-restore-file {
margin-bottom: var(--spacing-04);
}
.history-version-metadata-users {
display: inline;
vertical-align: bottom;
> li {
display: inline-flex;
align-items: center;
margin-right: var(--spacing-04);
}
}
.history-version-changes {
> li {
margin-bottom: var(--spacing-02);
}
}
.history-version-user-badge-color {
--badge-size: 8px;
display: inline-block;
width: var(--badge-size);
height: var(--badge-size);
margin-right: var(--spacing-02);
border-radius: 2px;
}
.history-version-user-badge-text {
overflow-wrap: anywhere;
flex: 1;
}
.history-version-day,
.history-version-change-action,
.history-version-metadata-users,
.history-version-origin,
.history-version-saved-by {
color: var(--content-secondary);
}
.history-version-change-action {
overflow-wrap: anywhere;
}
.history-version-change-doc {
color: var(--content-primary);
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.history-version-divider-container {
padding: var(--spacing-03) var(--spacing-04);
}
.history-version-divider {
margin: 0;
border-color: var(--border-divider);
}
.history-version-badge {
margin-bottom: var(--spacing-02);
margin-right: var(--spacing-05);
height: unset;
white-space: normal;
overflow-wrap: anywhere;
.material-symbols {
font-size: inherit;
}
}
.history-version-label {
margin-bottom: var(--spacing-02);
&:last-child {
margin-bottom: initial;
}
}
.loading {
font-family: $font-family-serif;
}
.history-all-versions-loading {
position: sticky;
bottom: 0;
padding: var(--spacing-05) 0;
background-color: var(--bg-light-secondary);
text-align: center;
}
.history-version-saved-by {
.history-version-saved-by-label {
margin-right: var(--spacing-04);
}
}
.dropdown.open {
.history-version-dropdown-menu-btn {
background-color: rgb(var(--bg-dark-primary) 0.08);
box-shadow: initial;
}
}
.history-compare-btn,
.history-version-dropdown-menu-btn {
@include reset-button;
@include action-button;
padding: 0;
width: 30px;
height: 30px;
}
.history-loading-panel {
padding-top: 10rem;
font-family: $font-family-serif;
text-align: center;
}
.history-paywall-prompt {
padding: var(--history-change-list-padding);
.history-feature-list {
list-style: none;
padding-left: var(--spacing-04);
li {
margin-bottom: var(--spacing-06);
}
}
button {
width: 100%;
}
}
.history-version-faded .history-version-details {
max-height: 6em;
@include mask-image(linear-gradient(black 35%, transparent));
overflow: hidden;
}
.history-paywall-heading {
@include heading-sm;
@include premium-text;
font-family: inherit;
font-weight: 700;
margin-top: var(--spacing-08);
}
.history-content {
padding: var(--spacing-05);
}
}
.history-version-label-tooltip {
padding: 6px;
text-align: initial;
.history-version-label-tooltip-row {
margin-bottom: var(--spacing-03);
.history-version-label-tooltip-row-comment {
overflow-wrap: anywhere;
& .material-symbols {
font-size: inherit;
}
}
&:last-child {
margin-bottom: initial;
}
}
}
.history-version-dropdown-menu {
[role='menuitem'] {
padding: var(--spacing-05);
color: var(--content-primary);
&:hover,
&:focus {
color: var(--content-primary);
background-color: var(--bg-light-secondary);
}
}
}
.history-dropdown-icon {
color: var(--content-primary);
}
.history-dropdown-icon-inverted {
color: var(--neutral-10);
vertical-align: top;
}
.history-restore-promo-icon {
vertical-align: middle;
}
.history-error {
padding: 16px;
}

View file

@ -328,20 +328,25 @@
***************************************/ ***************************************/
.toggle-switch { .toggle-switch {
--toggle-switch-height: 26px;
--toggle-switch-padding: var(--spacing-01);
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
height: 26px; height: var(--toggle-switch-height);
margin-right: var(--spacing-03); margin-right: var(--spacing-03);
border-radius: var(--border-radius-full); border-radius: var(--border-radius-full);
background-color: var(--neutral-20); background-color: var(--neutral-20);
padding: var(--spacing-01); padding: var(--toggle-switch-padding);
} }
.toggle-switch-label { .toggle-switch-label {
display: inline-block; display: inline-block;
float: left; float: left;
font-weight: normal; font-weight: normal;
height: 100%;
// It seems we need to set the height explicitly rather than using 100% to get the button to display correctly in Blink
height: calc(var(--toggle-switch-height) - 2 * var(--toggle-switch-padding));
text-align: center; text-align: center;
margin: 0; margin: 0;
cursor: pointer; cursor: pointer;

View file

@ -657,17 +657,9 @@
} }
.dropdown-table-button-toggle { .dropdown-table-button-toggle {
padding: var(--spacing-04); @include action-button;
font-size: 0;
line-height: 1;
border-radius: 50%;
color: var(--content-primary);
background-color: transparent;
&:hover, padding: var(--spacing-04);
&:active {
background-color: rgba($neutral-90, 0.08);
}
} }
} }
} }

View file

@ -5,3 +5,8 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.loading {
display: inline-flex;
align-items: center;
}

View file

@ -0,0 +1,684 @@
import '../../../helpers/bootstrap-5'
import { useState } from 'react'
import ToggleSwitch from '../../../../../frontend/js/features/history/components/change-list/toggle-switch'
import ChangeList from '../../../../../frontend/js/features/history/components/change-list/change-list'
import {
EditorProviders,
USER_EMAIL,
USER_ID,
} from '../../../helpers/editor-providers'
import { HistoryProvider } from '../../../../../frontend/js/features/history/context/history-context'
import { updates } from '../fixtures/updates'
import { labels } from '../fixtures/labels'
import {
formatTime,
relativeDate,
} from '../../../../../frontend/js/features/utils/format-date'
const mountWithEditorProviders = (
component: React.ReactNode,
scope: Record<string, unknown> = {},
props: Record<string, unknown> = {}
) => {
cy.mount(
<EditorProviders scope={scope} {...props}>
<HistoryProvider>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<div className="history-react">{component}</div>
</div>
</HistoryProvider>
</EditorProviders>
)
}
describe('change list (Bootstrap 5)', function () {
const scope = {
ui: { view: 'history', pdfLayout: 'sideBySide', chatOpen: true },
}
const waitForData = () => {
cy.wait('@updates')
cy.wait('@labels')
cy.wait('@diff')
}
beforeEach(function () {
cy.intercept('GET', '/project/*/updates*', {
body: updates,
}).as('updates')
cy.intercept('GET', '/project/*/labels', {
body: labels,
}).as('labels')
cy.intercept('GET', '/project/*/filetree/diff*', {
body: { diff: [{ pathname: 'main.tex' }, { pathname: 'name.tex' }] },
}).as('diff')
window.metaAttributesCache.set('ol-inactiveTutorials', [
'react-history-buttons-tutorial',
])
})
describe('toggle switch', function () {
it('renders switch buttons', function () {
mountWithEditorProviders(
<ToggleSwitch labelsOnly={false} setLabelsOnly={() => {}} />
)
cy.findByLabelText(/all history/i)
cy.findByLabelText(/labels/i)
})
it('toggles "all history" and "labels" buttons', function () {
function ToggleSwitchWrapped({ labelsOnly }: { labelsOnly: boolean }) {
const [labelsOnlyLocal, setLabelsOnlyLocal] = useState(labelsOnly)
return (
<ToggleSwitch
labelsOnly={labelsOnlyLocal}
setLabelsOnly={setLabelsOnlyLocal}
/>
)
}
mountWithEditorProviders(<ToggleSwitchWrapped labelsOnly={false} />)
cy.findByLabelText(/all history/i).as('all-history')
cy.findByLabelText(/labels/i).as('labels')
cy.get('@all-history').should('be.checked')
cy.get('@labels').should('not.be.checked')
cy.get('@labels').click({ force: true })
cy.get('@all-history').should('not.be.checked')
cy.get('@labels').should('be.checked')
})
})
describe('tags', function () {
it('renders tags', function () {
mountWithEditorProviders(<ChangeList />, scope, {
user: {
id: USER_ID,
email: USER_EMAIL,
isAdmin: true,
},
})
waitForData()
cy.findByLabelText(/all history/i).click({ force: true })
cy.findAllByTestId('history-version-details').as('details')
cy.get('@details').should('have.length', 5)
// start with 2nd details entry, as first has no tags
cy.get('@details')
.eq(1)
.within(() => {
cy.findAllByTestId('history-version-badge').as('tags')
})
cy.get('@tags').should('have.length', 2)
cy.get('@tags').eq(0).should('contain.text', 'tag-2')
cy.get('@tags').eq(1).should('contain.text', 'tag-1')
// should have delete buttons
cy.get('@tags').each(tag =>
cy.wrap(tag).within(() => {
cy.findByRole('button', { name: /delete/i })
})
)
// 3rd details entry
cy.get('@details')
.eq(2)
.within(() => {
cy.findAllByTestId('history-version-badge').should('have.length', 0)
})
// 4th details entry
cy.get('@details')
.eq(3)
.within(() => {
cy.findAllByTestId('history-version-badge').as('tags')
})
cy.get('@tags').should('have.length', 2)
cy.get('@tags').eq(0).should('contain.text', 'tag-4')
cy.get('@tags').eq(1).should('contain.text', 'tag-3')
// should not have delete buttons
cy.get('@tags').each(tag =>
cy.wrap(tag).within(() => {
cy.findByRole('button', { name: /delete/i }).should('not.exist')
})
)
cy.findByLabelText(/labels/i).click({ force: true })
cy.findAllByTestId('history-version-details').as('details')
// first details on labels is always "current version", start testing on second
cy.get('@details').should('have.length', 3)
cy.get('@details')
.eq(1)
.within(() => {
cy.findAllByTestId('history-version-badge').as('tags')
})
cy.get('@tags').should('have.length', 2)
cy.get('@tags').eq(0).should('contain.text', 'tag-2')
cy.get('@tags').eq(1).should('contain.text', 'tag-1')
cy.get('@details')
.eq(2)
.within(() => {
cy.findAllByTestId('history-version-badge').as('tags')
})
cy.get('@tags').should('have.length', 3)
cy.get('@tags').eq(0).should('contain.text', 'tag-5')
cy.get('@tags').eq(1).should('contain.text', 'tag-4')
cy.get('@tags').eq(2).should('contain.text', 'tag-3')
})
it('deletes tag', function () {
mountWithEditorProviders(<ChangeList />, scope, {
user: {
id: USER_ID,
email: USER_EMAIL,
isAdmin: true,
},
})
waitForData()
cy.findByLabelText(/all history/i).click({ force: true })
const labelToDelete = 'tag-2'
cy.findAllByTestId('history-version-details').eq(1).as('details')
cy.get('@details').within(() => {
cy.findAllByTestId('history-version-badge').eq(0).as('tag')
})
cy.get('@tag').should('contain.text', labelToDelete)
cy.get('@tag').within(() => {
cy.findByRole('button', { name: /delete/i }).as('delete-btn')
})
cy.get('@delete-btn').click()
cy.findByRole('dialog').as('modal')
cy.get('@modal').within(() => {
cy.findByRole('heading', { name: /delete label/i })
})
cy.get('@modal').contains(
new RegExp(
`are you sure you want to delete the following label "${labelToDelete}"?`,
'i'
)
)
cy.get('@modal').within(() => {
cy.findByRole('button', { name: /cancel/i }).click()
})
cy.findByRole('dialog').should('not.exist')
cy.get('@delete-btn').click()
cy.findByRole('dialog').as('modal')
cy.intercept('DELETE', '/project/*/labels/*', {
statusCode: 500,
}).as('delete')
cy.get('@modal').within(() => {
cy.findByRole('button', { name: /delete/i }).click()
})
cy.wait('@delete')
cy.get('@modal').within(() => {
cy.findByRole('alert').within(() => {
cy.contains(/sorry, something went wrong/i)
})
})
cy.findByText(labelToDelete).should('have.length', 1)
cy.intercept('DELETE', '/project/*/labels/*', {
statusCode: 204,
}).as('delete')
cy.get('@modal').within(() => {
cy.findByRole('button', { name: /delete/i }).click()
})
cy.wait('@delete')
cy.findByText(labelToDelete).should('not.exist')
})
it('verifies that selecting the same list item will not trigger a new diff', function () {
mountWithEditorProviders(<ChangeList />, scope, {
user: {
id: USER_ID,
email: USER_EMAIL,
isAdmin: true,
},
})
waitForData()
const stub = cy.stub().as('diffStub')
cy.intercept('GET', '/project/*/filetree/diff*', stub).as('diff')
cy.findAllByTestId('history-version-details').eq(2).as('details')
cy.get('@details').click() // 1st click
cy.wait('@diff')
cy.get('@details').click() // 2nd click
cy.get('@diffStub').should('have.been.calledOnce')
})
})
describe('all history', function () {
beforeEach(function () {
mountWithEditorProviders(<ChangeList />, scope, {
user: {
id: USER_ID,
email: USER_EMAIL,
isAdmin: true,
},
})
waitForData()
})
it('shows grouped versions date', function () {
cy.findByText(relativeDate(updates.updates[0].meta.end_ts))
cy.findByText(relativeDate(updates.updates[1].meta.end_ts))
})
it('shows the date of the version', function () {
cy.findAllByTestId('history-version-details')
.eq(1)
.within(() => {
cy.findByTestId('history-version-metadata-time').should(
'have.text',
formatTime(updates.updates[0].meta.end_ts, 'Do MMMM, h:mm a')
)
})
})
it('shows change action', function () {
cy.findAllByTestId('history-version-details')
.eq(1)
.within(() => {
cy.findByTestId('history-version-change-action').should(
'have.text',
'Created'
)
})
})
it('shows changed document name', function () {
cy.findAllByTestId('history-version-details')
.eq(2)
.within(() => {
cy.findByTestId('history-version-change-doc').should(
'have.text',
updates.updates[2].pathnames[0]
)
})
})
it('shows users', function () {
cy.findAllByTestId('history-version-details')
.eq(1)
.within(() => {
cy.findByTestId('history-version-metadata-users')
.should('contain.text', 'You')
.and('contain.text', updates.updates[1].meta.users[1].first_name)
})
})
})
describe('labels only', function () {
beforeEach(function () {
mountWithEditorProviders(<ChangeList />, scope, {
user: {
id: USER_ID,
email: USER_EMAIL,
isAdmin: true,
},
})
waitForData()
cy.findByLabelText(/labels/i).click({ force: true })
})
it('shows the dropdown menu item for adding new labels', function () {
cy.findAllByTestId('history-version-details')
.eq(1)
.within(() => {
cy.findByRole('button', { name: /more actions/i }).click()
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', {
name: /label this version/i,
}).should('exist')
})
})
})
it('resets from compare to view mode when switching tabs', function () {
cy.findAllByTestId('history-version-details')
.eq(1)
.within(() => {
cy.findByRole('button', {
name: /Compare/i,
}).click()
})
cy.findByLabelText(/all history/i).click({ force: true })
cy.findAllByTestId('history-version-details').should($versions => {
const [selected, ...rest] = Array.from($versions)
expect(selected).to.have.attr('data-selected', 'selected')
expect(
rest.every(version => version.dataset.selected === 'belowSelected')
).to.be.true
})
})
it('opens the compare drop down and compares with selected version', function () {
cy.findByLabelText(/all history/i).click({ force: true })
cy.findAllByTestId('history-version-details')
.eq(3)
.within(() => {
cy.findByRole('button', {
name: /compare from this version/i,
}).click()
})
cy.findAllByTestId('history-version-details')
.eq(1)
.within(() => {
cy.get('[aria-label="Compare"]').click()
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', {
name: /compare up to this version/i,
}).click()
})
})
cy.findAllByTestId('history-version-details').should($versions => {
const [
aboveSelected,
upperSelected,
withinSelected,
lowerSelected,
belowSelected,
] = Array.from($versions)
expect(aboveSelected).to.have.attr('data-selected', 'aboveSelected')
expect(upperSelected).to.have.attr('data-selected', 'upperSelected')
expect(withinSelected).to.have.attr('data-selected', 'withinSelected')
expect(lowerSelected).to.have.attr('data-selected', 'lowerSelected')
expect(belowSelected).to.have.attr('data-selected', 'belowSelected')
})
})
})
describe('compare mode', function () {
beforeEach(function () {
mountWithEditorProviders(<ChangeList />, scope, {
user: {
id: USER_ID,
email: USER_EMAIL,
isAdmin: true,
},
})
waitForData()
})
it('compares versions', function () {
cy.findAllByTestId('history-version-details').should($versions => {
const [first, ...rest] = Array.from($versions)
expect(first).to.have.attr('data-selected', 'selected')
rest.forEach(version =>
// Based on the fact that we are selecting first version as we load the page
// Every other version will be belowSelected
expect(version).to.have.attr('data-selected', 'belowSelected')
)
})
cy.intercept('GET', '/project/*/filetree/diff*', {
body: { diff: [{ pathname: 'main.tex' }, { pathname: 'name.tex' }] },
}).as('compareDiff')
cy.findAllByTestId('history-version-details')
.last()
.within(() => {
cy.findByTestId('compare-icon-version').click()
})
cy.wait('@compareDiff')
})
})
describe('dropdown', function () {
beforeEach(function () {
mountWithEditorProviders(<ChangeList />, scope, {
user: {
id: USER_ID,
email: USER_EMAIL,
isAdmin: true,
},
})
waitForData()
})
it('adds badge/label', function () {
cy.findAllByTestId('history-version-details').eq(1).as('version')
cy.get('@version').within(() => {
cy.findByRole('button', { name: /more actions/i }).click()
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', {
name: /label this version/i,
}).click()
})
})
cy.intercept('POST', '/project/*/labels', req => {
req.reply(200, {
id: '64633ee158e9ef7da614c000',
comment: req.body.comment,
version: req.body.version,
user_id: USER_ID,
created_at: '2023-05-16T08:29:21.250Z',
user_display_name: 'john.doe',
})
}).as('addLabel')
const newLabel = 'my new label'
cy.findByRole('dialog').within(() => {
cy.findByRole('heading', { name: /add label/i })
cy.findByRole('button', { name: /cancel/i })
cy.findByRole('button', { name: /add label/i }).should('be.disabled')
cy.findByPlaceholderText(/new label name/i).as('input')
cy.get('@input').type(newLabel)
cy.findByRole('button', { name: /add label/i }).should('be.enabled')
cy.get('@input').type('{enter}')
})
cy.wait('@addLabel')
cy.get('@version').within(() => {
cy.findAllByTestId('history-version-badge').should($badges => {
const includes = Array.from($badges).some(badge =>
badge.textContent?.includes(newLabel)
)
expect(includes).to.be.true
})
})
})
it('downloads version', function () {
cy.intercept('GET', '/project/*/version/*/zip', { statusCode: 200 }).as(
'download'
)
cy.findAllByTestId('history-version-details')
.eq(0)
.within(() => {
cy.findByRole('button', { name: /more actions/i }).click()
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', {
name: /download this version/i,
}).click()
})
})
cy.wait('@download')
})
})
describe('paywall', function () {
const now = Date.now()
const oneMinuteAgo = now - 60 * 1000
const justOverADayAgo = now - 25 * 60 * 60 * 1000
const twoDaysAgo = now - 48 * 60 * 60 * 1000
const updates = {
updates: [
{
fromV: 3,
toV: 4,
meta: {
users: [
{
first_name: 'john.doe',
last_name: '',
email: 'john.doe@test.com',
id: '1',
},
],
start_ts: oneMinuteAgo,
end_ts: oneMinuteAgo,
},
labels: [],
pathnames: [],
project_ops: [{ add: { pathname: 'name.tex' }, atV: 3 }],
},
{
fromV: 1,
toV: 3,
meta: {
users: [
{
first_name: 'bobby.lapointe',
last_name: '',
email: 'bobby.lapointe@test.com',
id: '2',
},
],
start_ts: justOverADayAgo,
end_ts: justOverADayAgo - 10 * 1000,
},
labels: [],
pathnames: ['main.tex'],
project_ops: [],
},
{
fromV: 0,
toV: 1,
meta: {
users: [
{
first_name: 'john.doe',
last_name: '',
email: 'john.doe@test.com',
id: '1',
},
],
start_ts: twoDaysAgo,
end_ts: twoDaysAgo,
},
labels: [
{
id: 'label1',
comment: 'tag-1',
version: 0,
user_id: USER_ID,
created_at: justOverADayAgo,
},
],
pathnames: [],
project_ops: [{ add: { pathname: 'main.tex' }, atV: 0 }],
},
],
}
const labels = [
{
id: 'label1',
comment: 'tag-1',
version: 0,
user_id: USER_ID,
created_at: justOverADayAgo,
user_display_name: 'john.doe',
},
]
const waitForData = () => {
cy.wait('@updates')
cy.wait('@labels')
cy.wait('@diff')
}
beforeEach(function () {
cy.intercept('GET', '/project/*/updates*', {
body: updates,
}).as('updates')
cy.intercept('GET', '/project/*/labels', {
body: labels,
}).as('labels')
cy.intercept('GET', '/project/*/filetree/diff*', {
body: { diff: [{ pathname: 'main.tex' }, { pathname: 'name.tex' }] },
}).as('diff')
})
it('shows non-owner paywall', function () {
const scope = {
ui: {
view: 'history',
pdfLayout: 'sideBySide',
chatOpen: true,
},
}
mountWithEditorProviders(<ChangeList />, scope, {
user: {
id: USER_ID,
email: USER_EMAIL,
isAdmin: false,
},
})
waitForData()
cy.get('.history-paywall-prompt').should('have.length', 1)
cy.findAllByTestId('history-version').should('have.length', 2)
cy.get('.history-paywall-prompt button').should('not.exist')
})
it('shows owner paywall', function () {
const scope = {
ui: {
view: 'history',
pdfLayout: 'sideBySide',
chatOpen: true,
},
}
mountWithEditorProviders(<ChangeList />, scope, {
user: {
id: USER_ID,
email: USER_EMAIL,
isAdmin: false,
},
projectOwner: {
_id: USER_ID,
email: USER_EMAIL,
},
})
waitForData()
cy.get('.history-paywall-prompt').should('have.length', 1)
cy.findAllByTestId('history-version').should('have.length', 2)
cy.get('.history-paywall-prompt button').should('have.length', 1)
})
it('shows all labels in free tier', function () {
const scope = {
ui: {
view: 'history',
pdfLayout: 'sideBySide',
chatOpen: true,
},
}
mountWithEditorProviders(<ChangeList />, scope, {
user: {
id: USER_ID,
email: USER_EMAIL,
isAdmin: false,
},
projectOwner: {
_id: USER_ID,
email: USER_EMAIL,
},
})
waitForData()
cy.findByLabelText(/labels/i).click({ force: true })
// One pseudo-label for the current state, one for our label
cy.get('.history-version-label').should('have.length', 2)
})
})
})

View file

@ -31,7 +31,7 @@ const mountWithEditorProviders = (
) )
} }
describe('change list', function () { describe('change list (Bootstrap 3)', function () {
const scope = { const scope = {
ui: { view: 'history', pdfLayout: 'sideBySide', chatOpen: true }, ui: { view: 'history', pdfLayout: 'sideBySide', chatOpen: true },
} }
@ -363,7 +363,7 @@ describe('change list', function () {
cy.findAllByTestId('history-version-details') cy.findAllByTestId('history-version-details')
.eq(1) .eq(1)
.within(() => { .within(() => {
cy.findByRole('button', { name: /compare drop down/i }).click() cy.findByRole('button', { name: /compare/i }).click()
cy.findByRole('menu').within(() => { cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', { cy.findByRole('menuitem', {
name: /compare up to this version/i, name: /compare up to this version/i,