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,
"no-descending-specificity": 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 { 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 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 useAbortController from '../../../../shared/hooks/use-abort-controller'
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 { 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
@ -71,51 +79,55 @@ function AddLabelModal({ show, setShow, version }: AddLabelModalProps) {
}
return (
<AccessibleModal
<OLModal
show={show}
onExited={handleModalExited}
onHide={() => setShow(false)}
id="add-history-label"
>
<Modal.Header>
<Modal.Title>{t('history_add_label')}</Modal.Title>
</Modal.Header>
<form onSubmit={handleSubmit}>
<Modal.Body>
<OLModalHeader>
<OLModalTitle>{t('history_add_label')}</OLModalTitle>
</OLModalHeader>
<OLForm onSubmit={handleSubmit}>
<OLModalBody>
{isError && <ModalError error={responseError} />}
<FormGroup>
<input
<OLFormGroup>
<OLFormControl
ref={autoFocusedRef}
className="form-control"
type="text"
placeholder={t('history_new_label_name')}
required
value={comment}
onChange={(
e: React.ChangeEvent<HTMLInputElement & FormControl>
) => setComment(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setComment(e.target.value)
}
/>
</FormGroup>
</Modal.Body>
<Modal.Footer>
<button
type="button"
className="btn btn-secondary"
</OLFormGroup>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
disabled={isLoading}
onClick={() => setShow(false)}
>
{t('cancel')}
</button>
<button
</OLButton>
<OLButton
type="submit"
className="btn btn-primary"
variant="primary"
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')}
</button>
</Modal.Footer>
</form>
</AccessibleModal>
{t('history_add_label')}
</OLButton>
</OLModalFooter>
</OLForm>
</OLModal>
)
}

View file

@ -8,7 +8,8 @@ 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 { 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 { Trans, useTranslation } from 'react-i18next'
import MaterialIcon from '@/shared/components/material-icon'
@ -173,18 +174,29 @@ function AllHistoryList() {
if (showHistoryTutorial) {
popover = (
<Overlay
placement="left"
<OLOverlay
placement="left-start"
show={showHistoryTutorial}
rootClose
onHide={hidePopover}
// using scrollerRef to position the popover in the middle of the viewport
target={scrollerRef.current ?? undefined}
shouldUpdatePosition
target={scrollerRef.current}
// 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
id="popover-toolbar-overflow"
arrowOffsetTop={10}
<OLPopover
id="popover-react-history-tutorial"
bs3Props={{ arrowOffsetTop: 10 }}
title={
<span>
{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
]}
/>
</Popover>
</Overlay>
</OLPopover>
</OLOverlay>
)
} else if (showRestorePromo) {
popover = (
<Overlay
placement="left"
<OLOverlay
placement="left-start"
show={showRestorePromo}
rootClose
onHide={hidePopover}
// using scrollerRef to position the popover in the middle of the viewport
target={scrollerRef.current ?? undefined}
shouldUpdatePosition
target={scrollerRef.current}
bs3Props={{ shouldUpdatePosition: true }}
>
<Popover
id="popover-toolbar-overflow"
arrowOffsetTop={10}
<OLPopover
id="popover-history-restore-promo"
bs3Props={{ arrowOffsetTop: 10 }}
title={
<span>
{t('history_restore_promo_title')}
@ -255,8 +267,8 @@ function AllHistoryList() {
/>,
]}
/>
</Popover>
</Overlay>
</OLPopover>
</OLOverlay>
)
}
@ -330,7 +342,9 @@ function AllHistoryList() {
{showNonOwnerPaywall ? <NonOwnerPaywallPrompt /> : null}
{updatesLoadingState === 'loadingInitial' ||
updatesLoadingState === 'loadingUpdates' ? (
<LoadingSpinner />
<div className="history-all-versions-loading">
<LoadingSpinner />
</div>
) : null}
</div>
)

View file

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

View file

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

View file

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

View file

@ -1,6 +1,8 @@
import ActionsDropdown from './actions-dropdown'
import Icon from '../../../../../shared/components/icon'
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 = {
children: React.ReactNode
@ -23,7 +25,17 @@ function HistoryDropdown({
toolTipDescription={t('more_actions')}
setIsOpened={setIsOpened}
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]"
>

View file

@ -1,7 +1,7 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { MenuItem } from 'react-bootstrap'
import Icon from '../../../../../../shared/components/icon'
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 = {
@ -26,9 +26,15 @@ function AddLabel({
return (
<>
<MenuItem onClick={handleClick} {...props}>
<Icon type="tag" fw /> {t('history_label_this_version')}
</MenuItem>
<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}

View file

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

View file

@ -1,5 +1,7 @@
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'
type DownloadProps = {
@ -17,15 +19,21 @@ function Download({
const { t } = useTranslation()
return (
<MenuItem
<OLDropdownMenuItem
href={`/project/${projectId}/version/${version}/zip`}
download={`${projectId}_v${version}.zip`}
rel="noreferrer"
onClick={closeDropdown}
leadingIcon={
<BootstrapVersionSwitcher
bs3={<Icon type="cloud-download" fw />}
bs5={<MaterialIcon type="download" />}
/>
}
{...props}
>
<Icon type="cloud-download" fw /> {t('history_download_this_version')}
</MenuItem>
{t('history_download_this_version')}
</OLDropdownMenuItem>
)
}

View file

@ -1,12 +1,14 @@
import Icon from '@/shared/components/icon'
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 { 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'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
type RestoreProjectProps = {
projectId: string
@ -44,9 +46,18 @@ const RestoreProject = ({
return (
<>
<MenuItem onClick={handleClick}>
<Icon type="undo" fw /> {t('restore_project_to_this_version')}
</MenuItem>
<OLDropdownMenuItem
as="button"
leadingIcon={
<BootstrapVersionSwitcher
bs3={<Icon type="undo" fw />}
bs5={<MaterialIcon type="undo" />}
/>
}
onClick={handleClick}
>
{t('restore_project_to_this_version')}
</OLDropdownMenuItem>
<RestoreProjectModal
setShow={setShowModal}
show={showModal}

View file

@ -35,7 +35,7 @@ function HistoryVersionDetails({
// 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 clearfix', {
className={classnames('history-version-details', {
'history-version-selected':
selectionState === 'upperSelected' ||
selectionState === 'lowerSelected' ||

View file

@ -22,6 +22,7 @@ import { CompareVersionDropdownContentAllHistory } from './dropdown/compare-vers
import FileRestoreChange from './file-restore-change'
import HistoryResyncChange from './history-resync-change'
import ProjectRestoreChange from './project-restore-change'
import { bsVersion } from '@/features/utils/bootstrap-5'
type HistoryVersionProps = {
update: LoadedUpdate
@ -123,7 +124,10 @@ function HistoryVersion({
)}
{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' ? (
<CompareItems
updateRange={updateRange}

View file

@ -13,6 +13,7 @@ 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'
import { bsVersion } from '@/features/utils/bootstrap-5'
type LabelListItemProps = {
version: Version
@ -96,7 +97,10 @@ function LabelListItem({
) : null}
</HistoryDropdown>
{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' ? (
<CompareItems
updateRange={updateRange}

View file

@ -1,5 +1,5 @@
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
type ModalErrorProps = {
@ -15,10 +15,22 @@ function ModalError({ error }: ModalErrorProps) {
const { t } = useTranslation()
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

View file

@ -1,9 +1,12 @@
import { useState } from 'react'
import { forwardRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Modal } from 'react-bootstrap'
import Icon from '../../../../shared/components/icon'
import Tooltip from '../../../../shared/components/tooltip'
import AccessibleModal from '../../../../shared/components/accessible-modal'
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'
@ -15,118 +18,127 @@ import { LoadedLabel } from '../../services/types/label'
import { debugConsole } from '@/utils/debugging'
import { formatTimeBasedOnYear } from '@/features/utils/format-date'
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 = {
label: LoadedLabel
currentUserId: string
}
function ChangeTag({ label, currentUserId, ...props }: TagProps) {
const { isProjectOwner } = useEditorContext()
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 { 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)
const showConfirmationModal = (e: React.MouseEvent) => {
e.stopPropagation()
setShowDeleteModal(true)
}
reset()
}
const handleModalExited = () => {
if (!isSuccess) return
const localDeleteHandler = () => {
runAsync(deleteLabel(projectId, label.id, signal))
.then(() => setShowDeleteModal(false))
.catch(debugConsole.error)
}
if (!isPseudoCurrentStateLabel) {
removeUpdateLabel(label)
}
const responseError = error as unknown as {
response: Response
data?: {
message?: string
reset()
}
}
const showCloseButton = Boolean(
(isOwnedByCurrentUser || isProjectOwner) && !isPseudoCurrentStateLabel
)
const localDeleteHandler = () => {
runAsync(deleteLabel(projectId, label.id, signal))
.then(() => setShowDeleteModal(false))
.catch(debugConsole.error)
}
return (
<>
<Tag
prepend={<Icon type="tag" fw />}
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}
</Tag>
{!isPseudoCurrentStateLabel && (
<AccessibleModal
show={showDeleteModal}
onExited={handleModalExited}
onHide={() => setShowDeleteModal(false)}
id="delete-history-label"
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}
>
<Modal.Header>
<Modal.Title>{t('history_delete_label')}</Modal.Title>
</Modal.Header>
<Modal.Body>
{isError && <ModalError error={responseError} />}
<p>
{t('history_are_you_sure_delete_label')}&nbsp;
<strong>"{label.comment}"</strong>?
</p>
</Modal.Body>
<Modal.Footer>
<button
type="button"
className="btn btn-secondary"
disabled={isLoading}
onClick={() => setShowDeleteModal(false)}
>
{t('cancel')}
</button>
<button
type="button"
className="btn btn-danger"
disabled={isLoading}
onClick={localDeleteHandler}
>
{isLoading
? t('history_deleting_label')
: t('history_delete_label')}
</button>
</Modal.Footer>
</AccessibleModal>
)}
</>
)
}
{isPseudoCurrentStateLabel
? t('history_label_project_current_state')
: label.comment}
</OLTag>
{!isPseudoCurrentStateLabel && (
<OLModal
show={showDeleteModal}
onExited={handleModalExited}
onHide={() => setShowDeleteModal(false)}
id="delete-history-label"
>
<OLModalHeader>
<OLModalTitle>{t('history_delete_label')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
{isError && <ModalError error={responseError} />}
<p>
{t('history_are_you_sure_delete_label')}&nbsp;
<strong>"{label.comment}"</strong>?
</p>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
disabled={isLoading}
onClick={() => setShowDeleteModal(false)}
>
{t('cancel')}
</OLButton>
<OLButton
variant="danger"
disabled={isLoading}
isLoading={isLoading}
onClick={localDeleteHandler}
bs3Props={{
loading: isLoading
? t('history_deleting_label')
: t('history_delete_label'),
}}
>
{t('history_delete_label')}
</OLButton>
</OLModalFooter>
</OLModal>
)}
</>
)
}
)
ChangeTag.displayName = 'ChangeTag'
type LabelBadgesProps = {
showTooltip: boolean
@ -145,13 +157,14 @@ function TagTooltip({ label, currentUserId, showTooltip }: LabelBadgesProps) {
? currentLabelData.user_display_name
: t('anonymous')
return showTooltip && !isPseudoCurrentStateLabel ? (
<Tooltip
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">
<Icon type="tag" fw />
<OLTagIcon />
&nbsp;
{label.comment}
</b>
</div>
@ -165,9 +178,10 @@ function TagTooltip({ label, currentUserId, showTooltip }: LabelBadgesProps) {
}
id={label.id}
overlayProps={{ placement: 'left' }}
hidden={!showTooltip}
>
<ChangeTag label={label} currentUserId={currentUserId} />
</Tooltip>
</OLTooltip>
) : (
<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'
export function RestoreProjectErrorModal({
@ -9,26 +15,22 @@ export function RestoreProjectErrorModal({
const { t } = useTranslation()
return (
<Modal show onHide={resetErrorBoundary}>
<Modal.Header closeButton>
<Modal.Title>
<OLModal show onHide={resetErrorBoundary}>
<OLModalHeader closeButton>
<OLModalTitle>
{t('an_error_occured_while_restoring_project')}
</Modal.Title>
</Modal.Header>
<Modal.Body>
</OLModalTitle>
</OLModalHeader>
<OLModalBody>
{t(
'there_was_a_problem_restoring_the_project_please_try_again_in_a_few_moments_or_contact_us'
)}
</Modal.Body>
<Modal.Footer>
<Button
bsStyle={null}
className="btn-secondary"
onClick={resetErrorBoundary}
>
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={resetErrorBoundary}>
{t('close')}
</Button>
</Modal.Footer>
</Modal>
</OLButton>
</OLModalFooter>
</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 { useCallback } from 'react'
import { Button, Modal } from 'react-bootstrap'
import OLButton from '@/features/ui/components/ol/ol-button'
import { useTranslation } from 'react-i18next'
type RestoreProjectModalProps = {
@ -26,35 +31,31 @@ export const RestoreProjectModal = ({
}, [setShow])
return (
<AccessibleModal onHide={() => setShow(false)} show={show}>
<Modal.Header>
<Modal.Title>{t('restore_this_version')}</Modal.Title>
</Modal.Header>
<Modal.Body>
<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>
</Modal.Body>
<Modal.Footer>
<Button
className="btn btn-secondary"
bsStyle={null}
onClick={onCancel}
disabled={isRestoring}
>
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={onCancel} disabled={isRestoring}>
{t('cancel')}
</Button>
<Button
className="btn btn-primary"
bsStyle={null}
</OLButton>
<OLButton
variant="primary"
onClick={onRestore}
disabled={isRestoring}
isLoading={isRestoring}
bs3Props={{ loading: isRestoring ? t('restoring') : t('restore') }}
>
{isRestoring ? t('restoring') : t('restore')}
</Button>
</Modal.Footer>
</AccessibleModal>
{t('restore')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}

View file

@ -1,6 +1,6 @@
import { memo, useCallback, useState } from 'react'
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 useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
import { useProjectListContext } from '../../../../context/project-list-context'
@ -64,9 +64,9 @@ function CopyProjectMenuItem() {
return (
<>
<OlDropdownMenuItem onClick={handleOpenModal} as="button" tabIndex={-1}>
<OLDropdownMenuItem onClick={handleOpenModal} as="button" tabIndex={-1}>
{t('make_a_copy')}
</OlDropdownMenuItem>
</OLDropdownMenuItem>
<CloneProjectModal
show={showModal}
handleHide={handleCloseModal}

View file

@ -2,7 +2,7 @@ import { memo, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useProjectListContext } from '../../../../context/project-list-context'
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'
function RenameProjectMenuItem() {
@ -34,9 +34,9 @@ function RenameProjectMenuItem() {
return (
<>
<OlDropdownMenuItem onClick={handleOpenModal} as="button" tabIndex={-1}>
<OLDropdownMenuItem onClick={handleOpenModal} as="button" tabIndex={-1}>
{t('rename')}
</OlDropdownMenuItem>
</OLDropdownMenuItem>
<RenameProjectModal
handleCloseModal={handleCloseModal}
showModal={showModal}

View file

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

View file

@ -1,8 +1,8 @@
import { forwardRef } from 'react'
import Tooltip from '../tooltip'
import Tooltip from '../../../../shared/components/tooltip'
import classnames from 'classnames'
import { DropdownProps } from 'react-bootstrap'
import { MergeAndOverride } from '../../../../../types/utils'
import { MergeAndOverride } from '../../../../../../types/utils'
type CustomToggleProps = MergeAndOverride<
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 { MergeAndOverride } from '../../../../../../types/utils'
import classnames from 'classnames'
import { forwardRef } from 'react'
type TagProps = MergeAndOverride<
BadgeProps,
@ -13,50 +14,55 @@ type TagProps = MergeAndOverride<
}
>
function Tag({
prepend,
children,
contentProps,
closeBtnProps,
className,
...rest
}: TagProps) {
const { t } = useTranslation()
const Tag = forwardRef<HTMLElement, TagProps>(
(
{ prepend, children, contentProps, closeBtnProps, className, ...rest },
ref
) => {
const { t } = useTranslation()
const content = (
<>
{prepend && <span className="badge-prepend">{prepend}</span>}
<span className="badge-content">{children}</span>
</>
)
const content = (
<>
{prepend && <span className="badge-prepend">{prepend}</span>}
<span className="badge-content">{children}</span>
</>
)
return (
<Badge bg="light" className={classnames('badge-tag', className)} {...rest}>
{contentProps?.onClick ? (
<button
type="button"
className="badge-tag-content badge-tag-content-btn"
{...contentProps}
>
{content}
</button>
) : (
<span className="badge-tag-content" {...contentProps}>
{content}
</span>
)}
{closeBtnProps && (
<button
type="button"
className="badge-close"
aria-label={t('remove_tag', { tagName: children })}
{...closeBtnProps}
>
<MaterialIcon className="badge-close-icon" type="close" />
</button>
)}
</Badge>
)
}
return (
<Badge
ref={ref}
bg="light"
className={classnames('badge-tag', className)}
{...rest}
>
{contentProps?.onClick ? (
<button
type="button"
className="badge-tag-content badge-tag-content-btn"
{...contentProps}
>
{content}
</button>
) : (
<span className="badge-tag-content" {...contentProps}>
{content}
</span>
)}
{closeBtnProps && (
<button
type="button"
className="badge-close"
aria-label={t('remove_tag', { tagName: children })}
{...closeBtnProps}
>
<MaterialIcon className="badge-close-icon" type="close" />
</button>
)}
</Badge>
)
}
)
Tag.displayName = '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 BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
type OlDropdownMenuItemProps = DropdownItemProps & {
type OLDropdownMenuItemProps = DropdownItemProps & {
bs3Props?: MenuItemProps
}
function OlDropdownMenuItem(props: OlDropdownMenuItemProps) {
function OLDropdownMenuItem(props: OLDropdownMenuItemProps) {
const { bs3Props, ...rest } = props
const bs3MenuItemProps: MenuItemProps = {
children: rest.children,
children: rest.leadingIcon ? (
<>
{rest.leadingIcon}
&nbsp;
{rest.children}
</>
) : (
rest.children
),
onClick: rest.onClick,
href: rest.href,
download: rest.download,
...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,
show: bs5Props.show,
onHide: bs5Props.onHide,
onExited: bs5Props.onExited,
backdrop: bs5Props.backdrop,
animation: bs5Props.animation,
id: bs5Props.id,
@ -86,10 +87,15 @@ export function OLModalTitle({ children, ...props }: OLModalTitleProps) {
const bs3ModalProps: BS3ModalTitleProps = {
componentClass: bs5Props.as,
}
return (
<BootstrapVersionSwitcher
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) {
if (placement === bs5Props.placement) {
bs3OverlayProps.placement = bs5Props.placement
// BS5 has more placement options than BS3, such as "left-start", so these are mapped to "left" etc.
if (bs5Props.placement.startsWith(placement)) {
bs3OverlayProps.placement = placement
break
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -5,6 +5,7 @@ import {
DropdownHeader,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
import type { Meta } from '@storybook/react'
import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
type Args = React.ComponentProps<typeof DropdownMenu>
@ -141,42 +142,52 @@ export const Icon = (args: Args) => {
return (
<DropdownMenu show>
<li>
<DropdownItem
<OLDropdownMenuItem
disabled={args.disabled}
eventKey="1"
href="#/action-1"
leadingIcon="view_column_2"
>
Editor & PDF
</DropdownItem>
</OLDropdownMenuItem>
</li>
<li>
<DropdownItem
<OLDropdownMenuItem
active
eventKey="2"
href="#/action-2"
leadingIcon="terminal"
>
Editor only
</DropdownItem>
</OLDropdownMenuItem>
</li>
<li>
<DropdownItem
eventKey="2"
href="#/action-2"
<OLDropdownMenuItem
eventKey="3"
href="#/action-3"
leadingIcon="picture_as_pdf"
>
PDF only
</DropdownItem>
</OLDropdownMenuItem>
</li>
<li>
<DropdownItem
eventKey="2"
href="#/action-2"
<OLDropdownMenuItem
eventKey="4"
href="#/action-4"
leadingIcon="select_window"
>
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>
</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 type { Meta, StoryObj } from '@storybook/react'
@ -47,7 +47,7 @@ export const TagPrepend: Story = {
render: args => {
return (
<div className="small">
<BS3Tag prepend={<Icon type="tag" fw />} {...args} />
<BS3Tag prepend={<OLTagIcon />} {...args} />
</div>
)
},
@ -58,7 +58,7 @@ export const TagWithCloseButton: Story = {
return (
<div className="small">
<BS3Tag
prepend={<Icon type="tag" fw />}
prepend={<OLTagIcon />}
closeBtnProps={{
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 type { Meta, StoryObj } from '@storybook/react'
@ -41,7 +41,7 @@ export const TagDefault: Story = {
export const TagPrepend: Story = {
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 => {
return (
<Tag
prepend={<Icon type="tag" fw />}
prepend={<OLTagIcon />}
closeBtnProps={{
onClick: () => alert('Close triggered!'),
}}
@ -63,7 +63,7 @@ export const TagWithContentButtonAndCloseButton: Story = {
render: args => {
return (
<Tag
prepend={<Icon type="tag" fw />}
prepend={<OLTagIcon />}
contentProps={{
onClick: () => alert('Content button clicked!'),
}}

View file

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

View file

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

View file

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

View file

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

View file

@ -164,14 +164,14 @@
.loading-spinner-small {
border-width: 0.2em;
height: 20px;
width: 20px;
height: 1.25rem;
width: 1.25rem;
}
.loading-spinner-large {
border-width: 0.2em;
height: 24px;
width: 24px;
height: 1.5rem;
width: 1.5rem;
}
}
@ -187,11 +187,11 @@
justify-content: center;
.icon-small {
font-size: 20px;
font-size: 1.25rem;
}
.icon-large {
font-size: 24px;
font-size: 1.5rem;
}
.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 {
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 {
width: 100%;
height: 100%;

View file

@ -1,5 +1,6 @@
.popover {
@include shadow-md;
@include dark-bg;
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/review-panel';
@import 'editor/chat';
@import 'editor/history';
@import 'subscription';
@import 'editor/pdf';
@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-height: 26px;
--toggle-switch-padding: var(--spacing-01);
display: inline-flex;
align-items: center;
height: 26px;
height: var(--toggle-switch-height);
margin-right: var(--spacing-03);
border-radius: var(--border-radius-full);
background-color: var(--neutral-20);
padding: var(--spacing-01);
padding: var(--toggle-switch-padding);
}
.toggle-switch-label {
display: inline-block;
float: left;
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;
margin: 0;
cursor: pointer;

View file

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

View file

@ -5,3 +5,8 @@
align-items: 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 = {
ui: { view: 'history', pdfLayout: 'sideBySide', chatOpen: true },
}
@ -363,7 +363,7 @@ describe('change list', function () {
cy.findAllByTestId('history-version-details')
.eq(1)
.within(() => {
cy.findByRole('button', { name: /compare drop down/i }).click()
cy.findByRole('button', { name: /compare/i }).click()
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', {
name: /compare up to this version/i,