mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Add paywall to React history view (#12849)
* Implement history view paywall * Add tests and some CSS fallbacks * Make additional faded version above paywall non-clickable * Change isFaded to faded for consistency * Remove unused import * Add missing attribute * SHow all labels in free tier * Address review comments * Change Boolean conversion Co-authored-by: ilkin-overleaf <100852799+ilkin-overleaf@users.noreply.github.com> * Make adding or deleting a label show up in version list again * Refactor to use visibleUpdateCount rather than maintaining two separate update arrays * Removed unused import * Use data-testid instead on class * Round gradient values * Correct test selector --------- Co-authored-by: ilkin-overleaf <100852799+ilkin-overleaf@users.noreply.github.com> GitOrigin-RevId: a2b021f3f4d3b9eb1358edb2ee4aa7db1bc7240e
This commit is contained in:
parent
e89b4b4214
commit
70bae34bd8
17 changed files with 448 additions and 64 deletions
|
@ -50,6 +50,7 @@
|
|||
"are_you_affiliated_with_an_institution": "",
|
||||
"are_you_still_at": "",
|
||||
"ascending": "",
|
||||
"ask_proj_owner_to_upgrade_for_full_history": "",
|
||||
"ask_proj_owner_to_upgrade_for_git_bridge": "",
|
||||
"ask_proj_owner_to_upgrade_for_longer_compiles": "",
|
||||
"ask_proj_owner_to_upgrade_for_references_search": "",
|
||||
|
@ -161,6 +162,7 @@
|
|||
"created_at": "",
|
||||
"creating": "",
|
||||
"current_password": "",
|
||||
"currently_seeing_only_24_hrs_history": "",
|
||||
"currently_subscribed_to_plan": "",
|
||||
"customize_your_group_subscription": "",
|
||||
"date_and_owner": "",
|
||||
|
|
|
@ -2,15 +2,27 @@ import { Fragment, useEffect, useRef, useState } from 'react'
|
|||
import { useHistoryContext } from '../../context/history-context'
|
||||
import HistoryVersion from './history-version'
|
||||
import LoadingSpinner from '../../../../shared/components/loading-spinner'
|
||||
import { OwnerPaywallPrompt } from './owner-paywall-prompt'
|
||||
import { NonOwnerPaywallPrompt } from './non-owner-paywall-prompt'
|
||||
|
||||
function AllHistoryList() {
|
||||
const { updatesInfo, loadingState, fetchNextBatchOfUpdates } =
|
||||
useHistoryContext()
|
||||
const { updates, atEnd } = updatesInfo
|
||||
const {
|
||||
updatesInfo,
|
||||
loadingState,
|
||||
fetchNextBatchOfUpdates,
|
||||
currentUserIsOwner,
|
||||
} = useHistoryContext()
|
||||
const { visibleUpdateCount, updates, atEnd } = updatesInfo
|
||||
const scrollerRef = useRef<HTMLDivElement>(null)
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
const intersectionObserverRef = useRef<IntersectionObserver | null>(null)
|
||||
const [bottomVisible, setBottomVisible] = useState(false)
|
||||
const showPaywall =
|
||||
loadingState === 'ready' && updatesInfo.freeHistoryLimitHit
|
||||
const showOwnerPaywall = showPaywall && currentUserIsOwner
|
||||
const showNonOwnerPaywall = showPaywall && !currentUserIsOwner
|
||||
const visibleUpdates =
|
||||
visibleUpdateCount === null ? updates : updates.slice(0, visibleUpdateCount)
|
||||
|
||||
// Create an intersection observer that watches for any part of an element
|
||||
// positioned at the bottom of the list to be visible
|
||||
|
@ -63,15 +75,23 @@ function AllHistoryList() {
|
|||
<div ref={scrollerRef} className="history-all-versions-scroller">
|
||||
<div className="history-all-versions-container">
|
||||
<div ref={bottomRef} className="history-versions-bottom" />
|
||||
{updates.map((update, index) => (
|
||||
{visibleUpdates.map((update, index) => (
|
||||
<Fragment key={`${update.fromV}_${update.toV}`}>
|
||||
{update.meta.first_in_day && index > 0 && (
|
||||
<hr className="history-version-divider" />
|
||||
)}
|
||||
<HistoryVersion update={update} />
|
||||
<HistoryVersion
|
||||
update={update}
|
||||
faded={
|
||||
updatesInfo.freeHistoryLimitHit &&
|
||||
index === visibleUpdates.length - 1
|
||||
}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
{showOwnerPaywall ? <OwnerPaywallPrompt /> : null}
|
||||
{showNonOwnerPaywall ? <NonOwnerPaywallPrompt /> : null}
|
||||
{loadingState === 'ready' ? null : <LoadingSpinner />}
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -5,11 +5,13 @@ import { UpdateRange } from '../../services/types/update'
|
|||
type HistoryVersionDetailsProps = {
|
||||
children: React.ReactNode
|
||||
selected: boolean
|
||||
selectable: boolean
|
||||
} & UpdateRange
|
||||
|
||||
function HistoryVersionDetails({
|
||||
children,
|
||||
selected,
|
||||
selectable,
|
||||
fromV,
|
||||
toV,
|
||||
fromVTimestamp,
|
||||
|
@ -34,9 +36,10 @@ function HistoryVersionDetails({
|
|||
<div
|
||||
className={classnames('history-version-details', {
|
||||
'history-version-selected': selected,
|
||||
'history-version-selectable': selectable,
|
||||
})}
|
||||
data-testid="history-version-details"
|
||||
onClick={handleSelect}
|
||||
onClick={selectable ? handleSelect : undefined}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
|
|
@ -10,12 +10,14 @@ import { isVersionSelected } from '../../utils/history-details'
|
|||
import { relativeDate, formatTime } from '../../../utils/format-date'
|
||||
import { orderBy } from 'lodash'
|
||||
import { LoadedUpdate } from '../../services/types/update'
|
||||
import classNames from 'classnames'
|
||||
|
||||
type HistoryEntryProps = {
|
||||
update: LoadedUpdate
|
||||
faded: boolean
|
||||
}
|
||||
|
||||
function HistoryVersion({ update }: HistoryEntryProps) {
|
||||
function HistoryVersion({ update, faded }: HistoryEntryProps) {
|
||||
const { id: currentUserId } = useUserContext()
|
||||
const { projectId, selection } = useHistoryContext()
|
||||
|
||||
|
@ -23,7 +25,12 @@ function HistoryVersion({ update }: HistoryEntryProps) {
|
|||
const selected = isVersionSelected(selection, update.fromV, update.toV)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
data-testid="history-version"
|
||||
className={classNames({
|
||||
'history-version-faded': faded,
|
||||
})}
|
||||
>
|
||||
{update.meta.first_in_day && (
|
||||
<time className="history-version-day">
|
||||
{relativeDate(update.meta.end_ts)}
|
||||
|
@ -35,6 +42,7 @@ function HistoryVersion({ update }: HistoryEntryProps) {
|
|||
fromVTimestamp={update.meta.end_ts}
|
||||
toVTimestamp={update.meta.end_ts}
|
||||
selected={selected}
|
||||
selectable={!faded}
|
||||
>
|
||||
<div className="history-version-main-details">
|
||||
<time className="history-version-metadata-time">
|
||||
|
@ -59,15 +67,17 @@ function HistoryVersion({ update }: HistoryEntryProps) {
|
|||
/>
|
||||
<Origin origin={update.meta.origin} />
|
||||
</div>
|
||||
<HistoryVersionDropdown
|
||||
id={`${update.fromV}_${update.toV}`}
|
||||
projectId={projectId}
|
||||
isComparing={selection.comparing}
|
||||
isSelected={selected}
|
||||
fromV={update.fromV}
|
||||
toV={update.toV}
|
||||
updateMetaEndTimestamp={update.meta.end_ts}
|
||||
/>
|
||||
{faded ? null : (
|
||||
<HistoryVersionDropdown
|
||||
id={`${update.fromV}_${update.toV}`}
|
||||
projectId={projectId}
|
||||
isComparing={selection.comparing}
|
||||
isSelected={selected}
|
||||
fromV={update.fromV}
|
||||
toV={update.toV}
|
||||
updateMetaEndTimestamp={update.meta.end_ts}
|
||||
/>
|
||||
)}
|
||||
</HistoryVersionDetails>
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -44,6 +44,7 @@ function LabelsList() {
|
|||
fromVTimestamp={fromVTimestamp}
|
||||
toVTimestamp={toVTimestamp}
|
||||
selected={selected}
|
||||
selectable
|
||||
>
|
||||
<div className="history-version-main-details">
|
||||
{labels.map(label => (
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export function NonOwnerPaywallPrompt() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="history-paywall-prompt">
|
||||
<h2 className="history-paywall-heading">{t('premium_feature')}</h2>
|
||||
<p>{t('currently_seeing_only_24_hrs_history')}</p>
|
||||
<p>
|
||||
<strong>{t('ask_proj_owner_to_upgrade_for_full_history')}</strong>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import Icon from '../../../../shared/components/icon'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import * as eventTracking from '../../../../infrastructure/event-tracking'
|
||||
import StartFreeTrialButton from '../../../../shared/components/start-free-trial-button'
|
||||
import { paywallPrompt } from '../../../../main/account-upgrade'
|
||||
|
||||
function FeatureItem({ text }: { text: string }) {
|
||||
return (
|
||||
<li>
|
||||
<Icon type="check" /> {text}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export function OwnerPaywallPrompt() {
|
||||
const { t } = useTranslation()
|
||||
const [clickedFreeTrialButton, setClickedFreeTrialButton] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
eventTracking.send('subscription-funnel', 'editor-click-feature', 'history')
|
||||
paywallPrompt('history')
|
||||
}, [])
|
||||
|
||||
const handleFreeTrialClick = useCallback(() => {
|
||||
setClickedFreeTrialButton(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="history-paywall-prompt">
|
||||
<h2 className="history-paywall-heading">{t('premium_feature')}</h2>
|
||||
<p>{t('currently_seeing_only_24_hrs_history')}</p>
|
||||
<p>
|
||||
<strong>
|
||||
{t('upgrade_to_get_feature', { feature: 'full Project History' })}
|
||||
</strong>
|
||||
</p>
|
||||
<ul className="history-feature-list">
|
||||
<FeatureItem text={t('unlimited_projects')} />
|
||||
<FeatureItem
|
||||
text={t('collabs_per_proj', { collabcount: 'Multiple' })}
|
||||
/>
|
||||
<FeatureItem text={t('full_doc_history')} />
|
||||
<FeatureItem text={t('sync_to_dropbox')} />
|
||||
<FeatureItem text={t('sync_to_github')} />
|
||||
<FeatureItem text={t('compile_larger_projects')} />
|
||||
</ul>
|
||||
<p>
|
||||
<StartFreeTrialButton
|
||||
source="history"
|
||||
buttonProps={{ bsStyle: 'default', className: 'btn-premium' }}
|
||||
handleClick={handleFreeTrialClick}
|
||||
/>
|
||||
</p>
|
||||
{clickedFreeTrialButton ? (
|
||||
<p className="small">{t('refresh_page_after_starting_free_trial')}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -18,14 +18,12 @@ import { loadLabels } from '../utils/label'
|
|||
import { autoSelectFile } from '../utils/auto-select-file'
|
||||
import ColorManager from '../../../ide/colors/ColorManager'
|
||||
import moment from 'moment'
|
||||
import * as eventTracking from '../../../infrastructure/event-tracking'
|
||||
import { cloneDeep } from 'lodash'
|
||||
import {
|
||||
FetchUpdatesResponse,
|
||||
LoadedUpdate,
|
||||
Update,
|
||||
} from '../services/types/update'
|
||||
import { Nullable } from '../../../../../types/utils'
|
||||
import { Selection } from '../services/types/selection'
|
||||
|
||||
// Allow testing of infinite scrolling by providing query string parameters to
|
||||
|
@ -71,6 +69,10 @@ function useHistory() {
|
|||
const userId = user.id
|
||||
const projectId = project._id
|
||||
const projectOwnerId = project.owner?._id
|
||||
const userHasFullFeature = Boolean(
|
||||
project.features?.versioning || user.isAdmin
|
||||
)
|
||||
const currentUserIsOwner = projectOwnerId === userId
|
||||
|
||||
const [selection, setSelection] = useState<Selection>(selectionInitialState)
|
||||
|
||||
|
@ -78,6 +80,7 @@ function useHistory() {
|
|||
HistoryContextValue['updatesInfo']
|
||||
>({
|
||||
updates: [],
|
||||
visibleUpdateCount: null,
|
||||
atEnd: false,
|
||||
freeHistoryLimitHit: false,
|
||||
nextBeforeTimestamp: undefined,
|
||||
|
@ -86,19 +89,15 @@ function useHistory() {
|
|||
const [loadingState, setLoadingState] =
|
||||
useState<HistoryContextValue['loadingState']>('loadingInitial')
|
||||
const [error, setError] = useState(null)
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const [userHasFullFeature, setUserHasFullFeature] =
|
||||
useState<HistoryContextValue['userHasFullFeature']>(undefined)
|
||||
|
||||
const fetchNextBatchOfUpdates = useCallback(() => {
|
||||
const loadUpdates = (updatesData: Update[]) => {
|
||||
const dateTimeNow = new Date()
|
||||
const timestamp24hoursAgo = dateTimeNow.setDate(dateTimeNow.getDate() - 1)
|
||||
let { updates, freeHistoryLimitHit } = updatesInfo
|
||||
let { updates, freeHistoryLimitHit, visibleUpdateCount } = updatesInfo
|
||||
let previousUpdate = updates[updates.length - 1]
|
||||
let cutOffIndex: Nullable<number> = null
|
||||
|
||||
let loadedUpdates: LoadedUpdate[] = cloneDeep(updatesData)
|
||||
const loadedUpdates: LoadedUpdate[] = cloneDeep(updatesData)
|
||||
for (const [index, update] of loadedUpdates.entries()) {
|
||||
for (const user of update.meta.users) {
|
||||
if (user) {
|
||||
|
@ -114,28 +113,24 @@ function useHistory() {
|
|||
|
||||
previousUpdate = update
|
||||
|
||||
if (userHasFullFeature && update.meta.end_ts < timestamp24hoursAgo) {
|
||||
cutOffIndex = index || 1 // Make sure that we show at least one entry (to allow labelling).
|
||||
if (
|
||||
!userHasFullFeature &&
|
||||
visibleUpdateCount === null &&
|
||||
update.meta.end_ts < timestamp24hoursAgo
|
||||
) {
|
||||
// Make sure that we show at least one entry (to allow labelling), and
|
||||
// load one extra update that is displayed but faded out above the
|
||||
// paywall
|
||||
visibleUpdateCount = index + 1
|
||||
freeHistoryLimitHit = true
|
||||
if (projectOwnerId === userId) {
|
||||
eventTracking.send(
|
||||
'subscription-funnel',
|
||||
'editor-click-feature',
|
||||
'history'
|
||||
)
|
||||
eventTracking.sendMB('paywall-prompt', {
|
||||
'paywall-type': 'history',
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!userHasFullFeature && cutOffIndex != null) {
|
||||
loadedUpdates = loadedUpdates.slice(0, cutOffIndex)
|
||||
return {
|
||||
updates: updates.concat(loadedUpdates),
|
||||
visibleUpdateCount,
|
||||
freeHistoryLimitHit,
|
||||
}
|
||||
|
||||
return { updates: updates.concat(loadedUpdates), freeHistoryLimitHit }
|
||||
}
|
||||
|
||||
if (updatesInfo.atEnd || loadingState === 'loadingUpdates') return
|
||||
|
@ -156,13 +151,15 @@ function useHistory() {
|
|||
setLabels(loadLabels(labels, lastUpdateToV))
|
||||
}
|
||||
|
||||
const { updates, freeHistoryLimitHit } = loadUpdates(updatesData)
|
||||
const { updates, visibleUpdateCount, freeHistoryLimitHit } =
|
||||
loadUpdates(updatesData)
|
||||
|
||||
const atEnd =
|
||||
nextBeforeTimestamp == null || freeHistoryLimitHit || !updates.length
|
||||
|
||||
setUpdatesInfo({
|
||||
updates,
|
||||
visibleUpdateCount,
|
||||
freeHistoryLimitHit,
|
||||
atEnd,
|
||||
nextBeforeTimestamp,
|
||||
|
@ -175,15 +172,7 @@ function useHistory() {
|
|||
.finally(() => {
|
||||
setLoadingState('ready')
|
||||
})
|
||||
}, [
|
||||
loadingState,
|
||||
labels,
|
||||
projectId,
|
||||
projectOwnerId,
|
||||
userId,
|
||||
userHasFullFeature,
|
||||
updatesInfo,
|
||||
])
|
||||
}, [loadingState, labels, projectId, userHasFullFeature, updatesInfo])
|
||||
|
||||
const resetSelection = useCallback(() => {
|
||||
setSelection(selectionInitialState)
|
||||
|
@ -257,6 +246,7 @@ function useHistory() {
|
|||
labels,
|
||||
setLabels,
|
||||
userHasFullFeature,
|
||||
currentUserIsOwner,
|
||||
projectId,
|
||||
selection,
|
||||
setSelection,
|
||||
|
@ -272,6 +262,7 @@ function useHistory() {
|
|||
labels,
|
||||
setLabels,
|
||||
userHasFullFeature,
|
||||
currentUserIsOwner,
|
||||
projectId,
|
||||
selection,
|
||||
setSelection,
|
||||
|
|
|
@ -12,6 +12,7 @@ type LoadingState =
|
|||
export type HistoryContextValue = {
|
||||
updatesInfo: {
|
||||
updates: LoadedUpdate[]
|
||||
visibleUpdateCount: Nullable<number>
|
||||
atEnd: boolean
|
||||
nextBeforeTimestamp: number | undefined
|
||||
freeHistoryLimitHit: boolean
|
||||
|
@ -19,7 +20,8 @@ export type HistoryContextValue = {
|
|||
setUpdatesInfo: React.Dispatch<
|
||||
React.SetStateAction<HistoryContextValue['updatesInfo']>
|
||||
>
|
||||
userHasFullFeature: boolean | undefined
|
||||
userHasFullFeature: boolean
|
||||
currentUserIsOwner: boolean
|
||||
loadingState: LoadingState
|
||||
setLoadingState: React.Dispatch<
|
||||
React.SetStateAction<HistoryContextValue['loadingState']>
|
||||
|
|
|
@ -25,6 +25,7 @@ export const projectShape = {
|
|||
references: PropTypes.bool,
|
||||
mendeley: PropTypes.bool,
|
||||
zotero: PropTypes.bool,
|
||||
versioning: PropTypes.bool,
|
||||
}),
|
||||
publicAccessLevel: PropTypes.string,
|
||||
tokens: PropTypes.shape({
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
@versions-list-width: 320px;
|
||||
@history-toolbar-height: 40px;
|
||||
@history-change-list-padding: 16px;
|
||||
|
||||
history-root {
|
||||
height: 100%;
|
||||
|
@ -78,7 +79,7 @@ history-root {
|
|||
.history-toggle-switch-container,
|
||||
.history-version-day,
|
||||
.history-version-details {
|
||||
padding: 0 16px;
|
||||
padding: 0 @history-change-list-padding;
|
||||
}
|
||||
|
||||
.history-version-day {
|
||||
|
@ -98,7 +99,7 @@ history-root {
|
|||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
|
||||
&:hover {
|
||||
&.history-version-selectable:hover {
|
||||
cursor: pointer;
|
||||
background-color: @neutral-10;
|
||||
}
|
||||
|
@ -267,6 +268,36 @@ history-root {
|
|||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.history-paywall-prompt {
|
||||
padding: @history-change-list-padding;
|
||||
|
||||
.history-feature-list {
|
||||
list-style: none;
|
||||
padding-left: 8px;
|
||||
|
||||
li {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.history-version-faded .history-version-details {
|
||||
max-height: 6em;
|
||||
.mask-image(linear-gradient(black 35%, transparent));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.history-paywall-heading {
|
||||
.premium-text;
|
||||
font-family: inherit;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.history-version-label-tooltip {
|
||||
|
|
|
@ -176,6 +176,12 @@
|
|||
.btn-secondary;
|
||||
}
|
||||
|
||||
// Buttons relating to premium features have a special background
|
||||
.btn-premium {
|
||||
.premium-background;
|
||||
color: @white;
|
||||
}
|
||||
|
||||
.reset-btns {
|
||||
// reset all buttons back to their original colors
|
||||
.btn-danger {
|
||||
|
|
|
@ -448,6 +448,25 @@
|
|||
filter: e(%('progid:DXImageTransform.Microsoft.gradient(enabled = false)'));
|
||||
}
|
||||
|
||||
.mask-image(@gradient) {
|
||||
// It has to be this way round, otherwise Less removes the prefixed version, for some reason
|
||||
mask-image: @gradient;
|
||||
-webkit-mask-image: @gradient;
|
||||
}
|
||||
|
||||
.premium-background {
|
||||
background-image: @premium-gradient;
|
||||
}
|
||||
|
||||
.premium-text {
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
color: @white; // Fallback
|
||||
background-color: @blue-70; // Fallback
|
||||
.premium-background;
|
||||
}
|
||||
|
||||
// Retina images
|
||||
//
|
||||
// Short retina mixin for setting background-image and -size
|
||||
|
|
|
@ -38,6 +38,8 @@
|
|||
@blue: #405ebf;
|
||||
@blue-10: #f1f4f9;
|
||||
@blue-30: #97b6e5;
|
||||
@blue-40: #6597e0;
|
||||
@blue-70: #214475;
|
||||
@blueDark: #040d2d;
|
||||
@green: #46a546;
|
||||
@green-10: #ebf6ea;
|
||||
|
@ -1174,3 +1176,11 @@
|
|||
@history-react-header-color: #fff;
|
||||
@history-react-separator-color: @neutral-80;
|
||||
@history-main-bg: #fff;
|
||||
|
||||
// Gradients
|
||||
@premium-gradient: linear-gradient(
|
||||
246deg,
|
||||
@blue-70 0%,
|
||||
#254c84 29%,
|
||||
@blue-40 97%
|
||||
);
|
||||
|
|
|
@ -959,3 +959,11 @@
|
|||
@history-react-header-color: #fff;
|
||||
@history-react-separator-color: @neutral-80;
|
||||
@history-main-bg: #fff;
|
||||
|
||||
// Gradients
|
||||
@premium-gradient: linear-gradient(
|
||||
246deg,
|
||||
@blue-70 0%,
|
||||
#254c84 29%,
|
||||
@blue-40 97%
|
||||
);
|
||||
|
|
|
@ -1,17 +1,22 @@
|
|||
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 } from '../../../helpers/editor-providers'
|
||||
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'
|
||||
|
||||
const mountWithEditorProviders = (
|
||||
component: React.ReactNode,
|
||||
scope: Record<string, unknown> = {}
|
||||
scope: Record<string, unknown> = {},
|
||||
props: Record<string, unknown> = {}
|
||||
) => {
|
||||
cy.mount(
|
||||
<EditorProviders scope={scope}>
|
||||
<EditorProviders scope={scope} {...props}>
|
||||
<HistoryProvider>
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<div className="history-react">{component}</div>
|
||||
|
@ -79,7 +84,13 @@ describe('change list', function () {
|
|||
})
|
||||
|
||||
it('renders tags', function () {
|
||||
mountWithEditorProviders(<ChangeList />, scope)
|
||||
mountWithEditorProviders(<ChangeList />, scope, {
|
||||
user: {
|
||||
id: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
isAdmin: true,
|
||||
},
|
||||
})
|
||||
waitForData()
|
||||
|
||||
cy.findByLabelText(/all history/i).click({ force: true })
|
||||
|
@ -144,7 +155,13 @@ describe('change list', function () {
|
|||
})
|
||||
|
||||
it('deletes tag', function () {
|
||||
mountWithEditorProviders(<ChangeList />, scope)
|
||||
mountWithEditorProviders(<ChangeList />, scope, {
|
||||
user: {
|
||||
id: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
isAdmin: true,
|
||||
},
|
||||
})
|
||||
waitForData()
|
||||
|
||||
cy.findByLabelText(/all history/i).click({ force: true })
|
||||
|
@ -187,6 +204,8 @@ describe('change list', function () {
|
|||
cy.contains(/sorry, something went wrong/i)
|
||||
})
|
||||
})
|
||||
cy.findByText(labelToDelete).should('have.length', 1)
|
||||
|
||||
cy.intercept('DELETE', '/project/*/labels/*', {
|
||||
statusCode: 204,
|
||||
}).as('delete')
|
||||
|
@ -197,4 +216,189 @@ describe('change list', function () {
|
|||
cy.findByText(labelToDelete).should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
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 })
|
||||
cy.get('.history-version-label').should('have.length', 1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -24,6 +24,10 @@ export const USER_EMAIL = 'testuser@example.com'
|
|||
export function EditorProviders({
|
||||
user = { id: USER_ID, email: USER_EMAIL },
|
||||
projectId = PROJECT_ID,
|
||||
projectOwner = {
|
||||
_id: '124abd',
|
||||
email: 'owner@example.com',
|
||||
},
|
||||
rootDocId = '_root_doc_id',
|
||||
socket = {
|
||||
on: sinon.stub(),
|
||||
|
@ -77,10 +81,7 @@ export function EditorProviders({
|
|||
project: {
|
||||
_id: window.project_id,
|
||||
name: PROJECT_NAME,
|
||||
owner: {
|
||||
_id: '124abd',
|
||||
email: 'owner@example.com',
|
||||
},
|
||||
owner: projectOwner,
|
||||
features,
|
||||
rootDoc_id: rootDocId,
|
||||
rootFolder,
|
||||
|
|
Loading…
Reference in a new issue