Add timeout upgrade prompt to the new compile UI (#3528)

* Uncouple account-upgrade and exposted-settings from Angular

* Mock socket shim with the correct methods

* Extract timeout upgrade prompt to a component

GitOrigin-RevId: ee8058b38bf5e20924a21f40d32c5bb0ee06c555
This commit is contained in:
Paulo Jorge Reis 2021-01-14 15:16:54 +00:00 committed by Copybot
parent c4b9cc1e25
commit 11e58b5844
17 changed files with 272 additions and 132 deletions

View file

@ -369,7 +369,8 @@ module.exports = function(webRouter, privateApiRouter, publicApiRouter) {
sentryAllowedOriginRegex: Settings.sentry.allowedOriginRegex,
sentryDsn: Settings.sentry.publicDSN,
sentryEnvironment: Settings.sentry.environment,
sentryRelease: Settings.sentry.release
sentryRelease: Settings.sentry.release,
enableSubscriptions: Settings.enableSubscriptions
}
next()
})

View file

@ -1,55 +1,82 @@
[
"ask_proj_owner_to_upgrade_for_longer_compiles",
"auto_compile",
"autocompile_disabled_reason",
"autocompile_disabled",
"autocomplete",
"autocomplete_references",
"autocomplete",
"blocked_filename",
"cancel",
"clear_cached_files",
"clsi_maintenance",
"clsi_unavailable",
"collabs_per_proj",
"collapse",
"common",
"compile_error_description",
"compile_error_entry_description",
"compile_larger_projects",
"compile_mode",
"compile_terminated_by_user",
"compiling",
"conflicting_paths_found",
"copy_project",
"copy",
"copying",
"create",
"creating",
"delete",
"deleting",
"dismiss_error_popup",
"done",
"download_file",
"download_pdf",
"duplicate_file",
"duplicate_paths_found",
"editing",
"error",
"expand",
"fast",
"file_outline",
"file_already_exists",
"file_already_exists_in_this_location",
"blocked_filename",
"file_already_exists",
"file_outline",
"file_tree_badge_tooltip",
"files_cannot_include_invalid_characters",
"find_out_more_about_the_file_outline",
"first_error_popup_label",
"following_paths_conflict",
"free_accounts_have_timeout_upgrade_to_increase",
"full_doc_history",
"full_screen",
"generic_something_went_wrong",
"go_to_error_location",
"headers",
"hide_outline",
"hotkeys",
"ignore_validation_errors",
"invalid_file_name",
"latex_error",
"learn_how_to_make_documents_compile_quickly",
"loading",
"log_entry_description",
"log_hint_extra_info",
"main_file_not_found",
"navigation",
"math_display",
"math_inline",
"n_errors_plural",
"n_errors",
"n_items",
"n_warnings_plural",
"n_warnings",
"navigate_log_source",
"navigation",
"new_file",
"new_folder",
"new_name",
"no_messages",
"normal",
"off",
"ok",
"on",
"other_logs_and_files",
"other_output_files",
@ -58,7 +85,9 @@
"pdf_compile_try_again",
"pdf_rendering_error",
"please_compile_pdf_before_download",
"please_refresh",
"please_set_main_file",
"plus_upgraded_accounts_receive",
"proj_timed_out_reason",
"project_flagged_too_many_compiles",
"project_too_large_please_reduce",
@ -67,6 +96,8 @@
"raw_logs",
"recompile_from_scratch",
"recompile",
"refresh",
"rename",
"review",
"run_syntax_check_now",
"send_first_message",
@ -75,13 +106,21 @@
"something_went_wrong_rendering_pdf",
"somthing_went_wrong_compiling",
"split_screen",
"start_free_trial",
"stop_compile",
"stop_on_validation_error",
"sure_you_want_to_delete",
"sync_to_dropbox",
"sync_to_github",
"terminated",
"timedout",
"toggle_compile_options_menu",
"toggle_output_files_list",
"too_recently_compiled",
"total_words",
"unlimited_projects",
"upgrade_for_longer_compiles",
"upload",
"validation_issue_description",
"validation_issue_entry_description",
"view_all_errors",
@ -89,39 +128,7 @@
"view_pdf",
"view_warnings",
"we_cant_find_any_sections_or_subsections_in_this_file",
"your_message",
"your_project_has_errors",
"copy_project",
"copying",
"copy",
"new_name",
"recompile_from_scratch",
"run_syntax_check_now",
"toggle_compile_options_menu",
"sure_you_want_to_delete",
"delete",
"deleting",
"cancel",
"new_file",
"new_folder",
"create",
"creating",
"upload",
"rename",
"n_items",
"please_refresh",
"generic_something_went_wrong",
"refresh",
"duplicate_file",
"error",
"invalid_file_name",
"ok",
"refresh",
"word_count",
"total_words",
"headers",
"math_inline",
"math_display",
"done",
"file_tree_badge_tooltip"
]
"your_message",
"your_project_has_errors"
]

View file

@ -2,8 +2,17 @@ import React from 'react'
import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next'
import PreviewLogsPaneEntry from './preview-logs-pane-entry'
import Icon from '../../../shared/components/icon'
import { useApplicationContext } from '../../../shared/context/application-context'
import { useEditorContext } from '../../../shared/context/editor-context'
import { startFreeTrial } from '../../../main/account-upgrade'
function PreviewError({ name }) {
const { isProjectOwner } = useEditorContext()
const {
exposedSettings: { enableSubscriptions }
} = useApplicationContext()
const { t } = useTranslation()
let errorTitle
let errorContent
@ -53,17 +62,101 @@ function PreviewError({ name }) {
}
return errorTitle ? (
<PreviewLogsPaneEntry
headerTitle={errorTitle}
formattedContent={errorContent}
entryAriaLabel={t('compile_error_entry_description')}
level="error"
/>
<>
<PreviewLogsPaneEntry
headerTitle={errorTitle}
formattedContent={errorContent}
entryAriaLabel={t('compile_error_entry_description')}
level="error"
/>
{name === 'timedout' && enableSubscriptions ? (
<TimeoutUpgradePrompt isProjectOwner={isProjectOwner} />
) : null}
</>
) : null
}
function TimeoutUpgradePrompt({ isProjectOwner }) {
const { t } = useTranslation()
function handleStartFreeTrialClick() {
startFreeTrial('compile-timeout')
}
const timeoutUpgradePromptContent = (
<>
<p>{t('free_accounts_have_timeout_upgrade_to_increase')}</p>
<p>{t('plus_upgraded_accounts_receive')}:</p>
<div>
<ul className="list-unstyled">
<li>
<Icon type="check" />
&nbsp;
{t('unlimited_projects')}
</li>
<li>
<Icon type="check" />
&nbsp;
{t('collabs_per_proj', { collabcount: 'Multiple' })}
</li>
<li>
<Icon type="check" />
&nbsp;
{t('full_doc_history')}
</li>
<li>
<Icon type="check" />
&nbsp;
{t('sync_to_dropbox')}
</li>
<li>
<Icon type="check" />
&nbsp;
{t('sync_to_github')}
</li>
<li>
<Icon type="check" />
&nbsp;
{t('compile_larger_projects')}
</li>
</ul>
</div>
{isProjectOwner ? (
<p className="text-center">
<button
className="btn btn-success row-spaced-small"
onClick={handleStartFreeTrialClick}
>
{t('start_free_trial')}
</button>
</p>
) : null}
</>
)
return (
<PreviewLogsPaneEntry
headerTitle={
isProjectOwner
? t('upgrade_for_longer_compiles')
: t('ask_proj_owner_to_upgrade_for_longer_compiles')
}
formattedContent={timeoutUpgradePromptContent}
entryAriaLabel={
isProjectOwner
? t('upgrade_for_longer_compiles')
: t('ask_proj_owner_to_upgrade_for_longer_compiles')
}
level="success"
/>
)
}
PreviewError.propTypes = {
name: PropTypes.string.isRequired
}
TimeoutUpgradePrompt.propTypes = {
isProjectOwner: PropTypes.bool.isRequired
}
export default PreviewError

View file

@ -78,13 +78,15 @@ function PreviewLogEntryHeader({
'log-entry-header-error': level === 'error',
'log-entry-header-warning': level === 'warning',
'log-entry-header-typesetting': level === 'typesetting',
'log-entry-header-raw': level === 'raw'
'log-entry-header-raw': level === 'raw',
'log-entry-header-success': level === 'success'
})
const logEntryLocationBtnClasses = classNames('log-entry-header-link', {
'log-entry-header-link-error': level === 'error',
'log-entry-header-link-warning': level === 'warning',
'log-entry-header-link-typesetting': level === 'typesetting',
'log-entry-header-link-raw': level === 'raw'
'log-entry-header-link-raw': level === 'raw',
'log-entry-header-link-success': level === 'success'
})
const headerLogLocationTitle = t('navigate_log_source', {
location: file + (line ? `, ${line}` : '')
@ -255,7 +257,8 @@ PreviewLogsPaneEntry.propTypes = {
logType: PropTypes.string,
formattedContent: PropTypes.node,
extraInfoURL: PropTypes.string,
level: PropTypes.oneOf(['error', 'warning', 'typesetting', 'raw']).isRequired,
level: PropTypes.oneOf(['error', 'warning', 'typesetting', 'raw', 'success'])
.isRequired,
customClass: PropTypes.string,
showSourceLocationLink: PropTypes.bool,
showCloseButton: PropTypes.bool,

View file

@ -57,8 +57,8 @@ import './services/validateCaptchaV3'
import './services/wait-for'
import './filters/formatDate'
import './main/event'
import './main/account-upgrade'
import './main/exposed-settings'
import './main/account-upgrade-angular'
import './main/exposed-settings-angular'
import './main/system-messages'
import '../../modules/modules-ide.js'

View file

@ -3,6 +3,7 @@ import HumanReadableLogs from '../../human-readable-logs/HumanReadableLogs'
import BibLogParser from 'libs/bib-log-parser'
import PreviewPane from '../../../features/preview/components/preview-pane'
import { react2angular } from 'react2angular'
import { rootContext } from '../../../shared/context/root-context'
import 'ace/ace'
const AUTO_COMPILE_MAX_WAIT = 5000
// We add a 1 second debounce to sending user changes to server if they aren't
@ -1152,6 +1153,11 @@ App.controller('ClearCacheModalController', function($scope, $modalInstance) {
$scope.cancel = () => $modalInstance.dismiss('cancel')
})
// Wrap React component as Angular component. Only needed for "top-level" component
App.component('previewPane', react2angular(PreviewPane))
App.component(
'previewPane',
react2angular(
rootContext.use(PreviewPane),
Object.keys(PreviewPane.propTypes)
)
)

View file

@ -12,7 +12,7 @@ import './main/token-access'
import './main/project-list/index'
import './main/account-settings'
import './main/clear-sessions'
import './main/account-upgrade'
import './main/account-upgrade-angular'
import './main/plans'
import './main/post-gateway'
import './main/user-membership'
@ -28,7 +28,7 @@ import './main/register-users'
import './main/subscription/team-invite-controller'
import './main/subscription/upgrade-subscription'
import './main/learn'
import './main/exposed-settings'
import './main/exposed-settings-angular'
import './main/affiliations/components/affiliationForm'
import './main/affiliations/components/inputSuggestions'
import './main/affiliations/controllers/UserAffiliationsController'

View file

@ -0,0 +1,13 @@
import App from '../base'
import { startFreeTrial, upgradePlan } from './account-upgrade'
App.controller('FreeTrialModalController', function($scope, eventTracking) {
$scope.buttonClass = 'btn-primary'
$scope.startFreeTrial = (source, version) =>
startFreeTrial(source, version, $scope, eventTracking)
})
App.controller('UpgradeModalController', function($scope, eventTracking) {
$scope.buttonClass = 'btn-primary'
$scope.upgradePlan = source => upgradePlan(source, $scope)
})

View file

@ -1,59 +1,49 @@
import App from '../base'
function startFreeTrial(source, version, $scope, eventTracking) {
const plan = 'collaborator_free_trial_7_days'
export default App.controller('FreeTrialModalController', function(
$scope,
eventTracking
) {
$scope.buttonClass = 'btn-primary'
$scope.startFreeTrial = function(source, version) {
const plan = 'collaborator_free_trial_7_days'
const w = window.open()
const go = function() {
let url
if (typeof ga === 'function') {
ga(
'send',
'event',
'subscription-funnel',
'upgraded-free-trial',
source
)
}
url = `/user/subscription/new?planCode=${plan}&ssp=true`
url = `${url}&itm_campaign=${source}`
if (version) {
url = `${url}&itm_content=${version}`
}
const w = window.open()
const go = function() {
let url
if (typeof ga === 'function') {
ga('send', 'event', 'subscription-funnel', 'upgraded-free-trial', source)
}
url = `/user/subscription/new?planCode=${plan}&ssp=true`
url = `${url}&itm_campaign=${source}`
if (version) {
url = `${url}&itm_content=${version}`
}
if ($scope) {
$scope.startedFreeTrial = true
}
if (eventTracking) {
eventTracking.sendMB('subscription-start-trial', { source, plan })
w.location = url
}
go()
w.location = url
}
})
App.controller('UpgradeModalController', function($scope, eventTracking) {
$scope.buttonClass = 'btn-primary'
go()
}
$scope.upgradePlan = function(source) {
const w = window.open()
const go = function() {
let url
if (typeof ga === 'function') {
ga('send', 'event', 'subscription-funnel', 'upgraded-plan', source)
}
url = '/user/subscription'
function upgradePlan(source, $scope) {
const w = window.open()
const go = function() {
let url
if (typeof ga === 'function') {
ga('send', 'event', 'subscription-funnel', 'upgraded-plan', source)
}
url = '/user/subscription'
if ($scope) {
$scope.startedFreeTrial = true
w.location = url
}
go()
w.location = url
}
})
go()
}
export { startFreeTrial, upgradePlan }

View file

@ -0,0 +1,4 @@
import App from '../base'
import ExposedSettings from './exposed-settings'
App.constant('ExposedSettings', ExposedSettings)

View file

@ -1,7 +1,3 @@
import App from '../base'
const ExposedSettings = window.ExposedSettings
App.constant('ExposedSettings', ExposedSettings)
export default ExposedSettings

View file

@ -1,15 +1,16 @@
import React, { createContext, useContext } from 'react'
import PropTypes from 'prop-types'
import ExposedSettings from '../../main/exposed-settings'
export const ApplicationContext = createContext()
export function ApplicationProvider({ children }) {
const applicationContextValue = {
user: window.user,
exposedSettings: ExposedSettings
}
return (
<ApplicationContext.Provider
value={{
user: window.user
}}
>
<ApplicationContext.Provider value={applicationContextValue}>
{children}
</ApplicationContext.Provider>
)
@ -20,8 +21,6 @@ ApplicationProvider.propTypes = {
}
export function useApplicationContext() {
const { user } = useContext(ApplicationContext)
return {
user
}
const applicationContext = useContext(ApplicationContext)
return applicationContext
}

View file

@ -4,12 +4,18 @@ import PropTypes from 'prop-types'
export const EditorContext = createContext()
export function EditorProvider({ children }) {
const ownerId =
window._ide.$scope.project && window._ide.$scope.project.owner
? window._ide.$scope.project.owner._id
: null
const editorContextValue = {
projectId: window.project_id,
isProjectOwner: ownerId === window.user.id
}
return (
<EditorContext.Provider
value={{
projectId: window.project_id
}}
>
<EditorContext.Provider value={editorContextValue}>
{children}
</EditorContext.Provider>
)
@ -20,8 +26,6 @@ EditorProvider.propTypes = {
}
export function useEditorContext() {
const { projectId } = useContext(EditorContext)
return {
projectId
}
const editorContext = useContext(EditorContext)
return editorContext
}

View file

@ -76,6 +76,14 @@
.btn-alert-variant(@ol-blue-gray-4);
}
.log-entry-header-success {
background-color: @green;
}
.log-entry-header-link-success {
.btn-alert-variant(@green);
}
.log-entry-header-title,
.log-entry-header-link {
font-family: @font-family-sans-serif;

View file

@ -27,11 +27,9 @@ export function tearDownMathJaxStubs() {
}
export function stubChatStore({ user }) {
window._ide = { socket: { on: sinon.stub(), removeListener: sinon.stub() } }
window.user = user
}
export function tearDownChatStore() {
delete window._ide
delete window.user
}

View file

@ -1,7 +1,8 @@
import React from 'react'
import { screen, render, fireEvent } from '@testing-library/react'
import { screen, fireEvent } from '@testing-library/react'
import PreviewLogsPane from '../../../../../frontend/js/features/preview/components/preview-logs-pane'
import sinon from 'sinon'
import { renderWithEditorContext } from '../../../helpers/render-with-context'
const { expect } = require('chai')
@ -60,7 +61,7 @@ entering extended mode
const noOp = () =>
describe('with logs', function() {
beforeEach(function() {
render(
renderWithEditorContext(
<PreviewLogsPane
logEntries={logEntries}
rawLog={sampleRawLog}
@ -125,7 +126,7 @@ entering extended mode
}
it('renders a validation entry for known issues', function() {
render(
renderWithEditorContext(
<PreviewLogsPane
validationIssues={sampleValidationIssues}
onLogEntryLocationClick={onLogEntryLocationClick}
@ -141,7 +142,7 @@ entering extended mode
})
it('ignores unknown issues', function() {
render(
renderWithEditorContext(
<PreviewLogsPane
validationIssues={{ unknownIssue: true }}
onLogEntryLocationClick={onLogEntryLocationClick}
@ -163,7 +164,7 @@ entering extended mode
}
it('renders an error entry for known errors', function() {
render(
renderWithEditorContext(
<PreviewLogsPane
errors={sampleErrors}
onLogEntryLocationClick={onLogEntryLocationClick}
@ -177,7 +178,7 @@ entering extended mode
})
it('ignores unknown errors', function() {
render(
renderWithEditorContext(
<PreviewLogsPane
errors={{ unknownIssue: true }}
onLogEntryLocationClick={onLogEntryLocationClick}

View file

@ -2,10 +2,27 @@ import React from 'react'
import { render } from '@testing-library/react'
import { ApplicationProvider } from '../../../frontend/js/shared/context/application-context'
import { EditorProvider } from '../../../frontend/js/shared/context/editor-context'
import sinon from 'sinon'
export function renderWithEditorContext(children, { user, projectId } = {}) {
export function renderWithEditorContext(
children,
{ user = { id: '123abd' }, projectId } = {}
) {
window.user = user || window.user
window.project_id = projectId != null ? projectId : window.project_id
window._ide = {
$scope: {
project: {
owner: {
_id: '124abd'
}
}
},
socket: {
on: sinon.stub(),
removeListener: sinon.stub()
}
}
return render(
<ApplicationProvider>
<EditorProvider>{children}</EditorProvider>