24 Commits

Author SHA1 Message Date
Mathias Jakobsen
b63ce40914 [web] Move PDF dark mode button to the right (#29886)
GitOrigin-RevId: 7f423b2fb4fab61d775bc77351f1cbbc152450d1
2025-11-26 09:06:00 +00:00
Jimmy Domagala-Tang
2aa2862a77 Feat: Allow Ai Bundle for Commons Licenses (#29413)
* allowing for writefullCommonsAccount within v1 affiliates to signal a commons ai bundle

* feat: update wf when features change

* feat: replace call to wf with metric to give them estimate of number of calls

* fix: acceptance tests for GroupDomainCaptureTests rely on features outlined in settings, where aiErrorAssistant feature is not listed since it is delivered through a module hook

GitOrigin-RevId: 8c2470c7e73b8a1e080bfc977469d35e66ca9db4
2025-11-26 09:05:56 +00:00
Mathias Jakobsen
4186321ed7 [web] Add dark mode setting to PDF viewer (#29813)
GitOrigin-RevId: 4eddfac30a934c44b451694fd5e18dd8d26ad910
2025-11-26 09:05:51 +00:00
Tim Down
763bede00a Merge pull request #29654 from overleaf/ac-ciam-confirm-email-storybook
[web] CIAM design for Email confirmation form

GitOrigin-RevId: 3e66c45fe20073eb0600b8243761dbe82d7dc6b2
2025-11-26 09:05:47 +00:00
Alf Eaton
0d9fa6c0a6 Merge pull request #29853 from overleaf/ae-workbench-updates-3
[workbench] small improvements

GitOrigin-RevId: c27529004bf1521af469e42b4764f2ddd76dd023
2025-11-26 09:05:40 +00:00
Alf Eaton
e590543bc5 Store PDF scroll position when the viewer unmounts (#29872)
GitOrigin-RevId: fc82c8db3d8cdf6a4857a4d2f3f62b181e40e067
2025-11-26 09:05:32 +00:00
Borja
2c00a7a3a4 fix: insert new line after inserting title, abstract or keywords (#29882)
GitOrigin-RevId: d8d79e95d9eb544adaff8850630df996461bacb9
2025-11-26 09:05:27 +00:00
Borja
4f5638348e Add keywords generation functionality (#29842)
GitOrigin-RevId: 1be8739496279af42ffbc299911db92c5caefece
2025-11-25 09:06:45 +00:00
Eric Mc Sween
1b2a52ad7f Merge pull request #29877 from overleaf/em-revert-octokit
Revert octonode to octokit migration

GitOrigin-RevId: 6a5819a0f25c96bea10bc0cae33ae90ee5038276
2025-11-25 09:06:41 +00:00
John Lees-Miller
b514ebcc8e Support analytics data in user export bundle
GitOrigin-RevId: 49739297f40831cf035e2a9d4f3343a8cb2d7fdb
2025-11-25 09:06:20 +00:00
John Lees-Miller
241a4b6b03 Use worker for analytics user export
GitOrigin-RevId: 3e49d483c0d93fa67986332e77e9928889aab7a7
2025-11-25 09:06:16 +00:00
Davinder Singh
ec9d2d83d8 Tear down compile-timeout-remove-info (#29719)
* tearing down the test, and removing the timeout help message

* running make cleanup_unused_locales

* npm run extract:translations

GitOrigin-RevId: 54ee19d18a86a9061ff23d6dbd8375ae0bdf73c2
2025-11-25 09:06:05 +00:00
Gernot Schulz
e7c92b15cc Merge pull request #29870 from overleaf/gs-jenkins-project-opt
Add `--project` option to Jenkins issue script

GitOrigin-RevId: b38f181e1dc22687654d17c7a6b7bf185848620b
2025-11-25 09:05:57 +00:00
Eric Mc Sween
ba61b0dfd4 Merge pull request #29691 from overleaf/em-promisify-github-manager
Replace octonode with octokit in github-sync

GitOrigin-RevId: dfe4f94bed8c4c41a2234860ce2b3443eb076cb9
2025-11-25 09:05:53 +00:00
Rebeka Dekany
b4bfff1b67 Improve Server Pro tests to use semantic selectors (#29790)
* Replace placeholders with labels

* Add 'Close dialog' label to modal close button to distinguish from footer Close button

* Add and translate heading on the not found page

* Update textarea to have id matching label's for attribute
Simplify test for template description textarea

* Label PDF zoom level dropdown button

* Improve test selectors to use semantic roles and accessible names

GitOrigin-RevId: d215ddca30ddf844cfffbcf0e528a601b134d772
2025-11-25 09:05:48 +00:00
Simon Gardner
7dce5f0e25 Adds audit log entry for user Logout event
GitOrigin-RevId: 5a305166ba0e017ae7cb3d426cdae541e8db62c3
2025-11-25 09:05:38 +00:00
Mathias Jakobsen
af148bafb3 Merge pull request #28209 from overleaf/mj-dropbox-stop-archived-sync
[web] Avoid applying TPDS updates to archived and trashed projects

GitOrigin-RevId: c33bfb1ae5975cd98c81b76713fb99369816b188
2025-11-25 09:05:33 +00:00
Mathias Jakobsen
547ae18f73 Merge pull request #29313 from overleaf/mj-ars-user-feature
[web] Convert references search to user level feature

GitOrigin-RevId: 285f68ea6624e7bad752a308cf10d06401a3e33c
2025-11-25 09:05:29 +00:00
Simon Gardner
377e431146 Update delete user audit log for managed users
GitOrigin-RevId: 5b5b164ebf5b077ec783709d6846a52e94d21565
2025-11-25 09:05:22 +00:00
Domagoj Kriskovic
6e2f999a11 Fix import from CollaboratorsHandler.js to CollaboratorsHandler.mjs (#29863)
GitOrigin-RevId: 35f7fd558a127c094b65104e0775c67dca4f96f6
2025-11-25 09:05:18 +00:00
David
dd8451d51d Create script to update track changes stored format for all users (#26876)
* Create script to update track changes stored format for all users

* Create script to update track changes stored format for all users

* Dont remove guests

* Fix typo

* fix updateOne

---------

Co-authored-by: Domagoj Kriskovic <dom.kriskovic@overleaf.com>
GitOrigin-RevId: c2fddc46b78e84807243a752facdf0215d3ff082
2025-11-25 09:05:14 +00:00
David
44bd4ab790 Merge pull request #29802 from overleaf/dp-revert-beta
Remove hack to give beta users access to the editor redesign

GitOrigin-RevId: 40c5c2db9033dfb6569d655275e280ce29b898e2
2025-11-25 09:05:03 +00:00
David
5eca30db82 Merge pull request #29800 from overleaf/dp-outline-scroll
Remove unnecessary scrollbar from empty file outline

GitOrigin-RevId: 3866fa3653088665ee21f389174a8ad1c91b9872
2025-11-25 09:04:59 +00:00
Gernot Schulz
93e6a230c7 Merge pull request #29786 from overleaf/gs-jenkins-gh-issue-web
web: Open GitHub issue in case of pipeline failure
GitOrigin-RevId: 0adbeb4d60cd658eccde42b4ce2874ce2623908f
2025-11-25 09:04:54 +00:00
98 changed files with 2184 additions and 582 deletions

View File

@@ -0,0 +1,11 @@
module.exports = {
rules: {
'no-unnecessary-trans': require('./no-unnecessary-trans'),
'prefer-kebab-url': require('./prefer-kebab-url'),
'should-unescape-trans': require('./should-unescape-trans'),
'no-generated-editor-themes': require('./no-generated-editor-themes'),
'require-script-runner': require('./require-script-runner'),
'require-vi-doMock-valid-path': require('./require-vi-doMock-valid-path'),
'require-loading-label': require('./require-loading-label'),
},
}

View File

@@ -0,0 +1,21 @@
module.exports = {
meta: {
type: 'error',
docs: {
description:
'Prohibit CodeMirror themes that are generated in a function',
},
},
create(context) {
return {
':matches(ArrowFunctionExpression, FunctionDeclaration, FunctionExpression) CallExpression > MemberExpression[object.name="EditorView"]:matches([property.name="theme"],[property.name="baseTheme"])'(
node
) {
context.report({
node,
message: `EditorView.theme and EditorView.baseTheme each add CSS to the page for every instance of the theme. Store the theme in a variable and reuse it instead.`,
})
},
}
},
}

View File

@@ -0,0 +1,43 @@
module.exports = {
meta: {
type: 'problem',
fixable: 'code',
docs: {
description: 'Prohibit Trans with no components or values',
},
},
create(context) {
return {
'JSXOpeningElement[name.name="Trans"]'(node) {
const attributes = new Map(
node.attributes.map(attr => [attr.name.name, attr])
)
if (!attributes.has('components')) {
if (node.parent.children.length > 0) {
context.report({
node,
message: `Trans components must not have child elements`,
})
} else if (attributes.has('values')) {
context.report({
node,
message: `Use t('…') when there are no components`,
})
} else {
context.report({
node,
message: `Use t('…') when there are no components`,
fix(fixer) {
const i18nKey = attributes.get('i18nKey').value.value
// Note: Prettier can fix indentation
return fixer.replaceText(node.parent, `{t('${i18nKey}')}`)
},
})
}
}
},
}
},
}

View File

@@ -0,0 +1,17 @@
{
"name": "@overleaf/eslint-plugin",
"version": "0.1.0",
"author": "Overleaf (https://www.overleaf.com)",
"license": "AGPL-3.0-only",
"main": "index.js",
"dependencies": {
"eslint": "^8.51.0",
"lodash": "^4.17.21"
},
"devDependencies": {
"@typescript-eslint/parser": "^8.30.1"
},
"scripts": {
"test": "node rules.test.js"
}
}

View File

@@ -0,0 +1,83 @@
// URL parts should be kebab-case, but we didn't have this rule in the past.
// The ESLint rule `prefer-kebab-url` will ignore these "legacy" URL parts.
const ignoreWords = {
snake: new Set([
'clear_saml_data',
'confirm_link',
'confirm_university_domain',
'create_recurly_account',
'current_history_content',
'current_user',
'default_email',
'disable_managed_users',
'doc_snapshot',
'enable_history_ranges_support',
'features_override',
'generate_password_reset_url',
'get_assignment',
'get_clone',
'health_check',
'institutional_emails',
'latest_template',
'link_after_saml_response',
'linked_file',
'metrics_segmentation',
'new_users',
'no_autostart_post_gateway',
'personal_info',
'planned_maintenance',
'refresh_features',
'register_admin',
'register_ldap_admin',
'register_saml_admin',
'restore_file',
'revert_file',
'saved_vers',
'send_test_email',
'session_maintenance',
'set_in_session',
'sign_in_to_link',
'split_test',
'sso_configuration_test',
'sso_email',
'sso_enrollment',
'track_changes',
'update_admin',
'user_details',
]),
camel: new Set([
'addWorkflowScope',
'aiErrorAssistant',
'beginAuth',
'brandVariationId',
'closeEditor',
'completeRegistration',
'deactivateOldProjects',
'deletedSubscription',
'disconnectAllUsers',
'editingSession',
'emailSubscription',
'enableManagedUsers',
'externalCollaboration',
'flushProjectToTpds',
'indexAll',
'offboardManagedUser',
'openEditor',
'perfTest',
'pollDropboxForUser',
'resendInvite',
'resendManagedUserInvite',
'salesContactForm',
'showSupport',
]),
other: new Set([
'Project',
'disableSSO',
'enableSSO',
'resendSSOLinkInvite',
'usersCSV',
]),
}
module.exports = { ignoreWords }

View File

@@ -0,0 +1,91 @@
const _ = require('lodash')
const { ignoreWords } = require('./prefer-kebab-url-ignore')
const removeTextBetweenBrackets = text => {
while (text.includes('[') || text.includes('(')) {
text = text.replaceAll(/\[[^[\]]*]/g, '')
text = text.replaceAll(/\([^()]*\)/g, '')
}
return text
}
const shouldIgnoreWord = str =>
str.includes(':') ||
str.includes('(') ||
str === '*' ||
str.match(/^[a-z0-9.]+$/) ||
ignoreWords.snake.has(str) ||
ignoreWords.camel.has(str) ||
ignoreWords.other.has(str)
const getSuggestion = routePath => {
if (typeof routePath === 'string') {
const kebabed = routePath
.split('/')
.map(word => (shouldIgnoreWord(word) ? word : _.kebabCase(word)))
.join('/')
return kebabed === routePath ? null : `'${kebabed}'`
}
if (routePath instanceof RegExp) {
const words = removeTextBetweenBrackets(routePath.source).match(/[\w-]+/g)
if (!words) return routePath
let newSource = routePath.source
for (const word of words) {
if (!shouldIgnoreWord(word)) {
newSource = newSource.replaceAll(
new RegExp(`\\b${word}\\b`, 'g'),
_.kebabCase(word)
)
}
}
const kebabed = new RegExp(newSource, routePath.flags)
return kebabed.source.toString() === routePath.source.toString()
? null
: kebabed
}
}
module.exports = {
meta: {
type: 'problem',
fixable: 'code',
hasSuggestions: true,
docs: {
description: 'Enforce using kebab-case for URL paths',
},
},
create: context => ({
CallExpression(node) {
if (
node.callee.type === 'MemberExpression' &&
node.arguments[0]?.type === 'Literal' &&
[/app/i, /router/i].some(callee =>
typeof callee === 'string'
? node.callee.object.name === callee
: callee.test(node.callee.object.name)
) &&
['get', 'post', 'put', 'delete'].includes(node.callee.property.name)
) {
const routePath = node.arguments[0].value
const suggestion = getSuggestion(routePath)
if (suggestion) {
context.report({
node: node.arguments[0],
message: 'Route path should be in kebab-case.',
suggest: [
{
desc: `Change to kebab-case: ${suggestion}`,
fix: fixer => fixer.replaceText(node.arguments[0], suggestion),
},
],
})
}
}
},
}),
}

View File

@@ -0,0 +1,49 @@
module.exports = {
meta: {
type: 'problem',
fixable: null,
docs: {
description: 'Require loadingLabel prop when isLoading is specified on OLButton',
},
schema: [],
},
create(context) {
return {
'JSXOpeningElement[name.name="OLButton"]'(node) {
const attributes = new Map(
node.attributes.map(attr => [
attr.name?.name,
attr
])
)
const isLoadingAttr = attributes.get('isLoading')
const loadingLabelAttr = attributes.get('loadingLabel')
if (isLoadingAttr && !loadingLabelAttr) {
const isLoadingValue = isLoadingAttr.value
if (
!isLoadingValue ||
(isLoadingValue.type === 'JSXExpressionContainer' &&
isLoadingValue.expression.type === 'Literal' &&
isLoadingValue.expression.value === true)
) {
context.report({
node: isLoadingAttr,
message: 'Button with isLoading prop must also specify loadingLabel',
})
} else if (
isLoadingValue.type === 'JSXExpressionContainer' &&
isLoadingValue.expression.type !== 'Literal'
) {
context.report({
node: isLoadingAttr,
message: 'Button with isLoading prop must also specify loadingLabel',
})
}
}
},
}
},
}

View File

@@ -0,0 +1,28 @@
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'Require Script Runner for scripts',
},
},
create(context) {
let hasImport = false
return {
ImportDeclaration(node) {
if (node.source.value.endsWith('lib/ScriptRunner.mjs')) {
hasImport = true
}
},
'Program:exit'() {
if (!hasImport) {
context.report({
loc: { line: 1, column: 0 },
message:
'Please use Script Runner for scripts. Refer to the developer manual (https://manual.dev-overleaf.com/development/code/web_scripts/#monitor-script-execution-and-usage-with-script-runner) for more information.',
})
}
},
}
},
}

View File

@@ -0,0 +1,121 @@
const path = require('node:path');
const fs = require('node:fs');
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Ensure vi.doMock first argument is a resolvable path.',
category: 'Best Practices',
recommended: false,
url: '',
},
fixable: 'code',
hasSuggestions: true,
schema: [],
messages: {
unresolvablePath: 'The path "{{pathValue}}" in vi.doMock() cannot be resolved relative to the current file.',
notAStringLiteral: 'The first argument of vi.doMock() must be (or resolve to) a string literal representing a path.',
noArguments: 'vi.doMock() called with no arguments.',
},
},
create(context) {
const currentFilePath = context.getFilename();
// ESLint can sometimes pass <text> or <input> for snippets not in a file
if (currentFilePath === '<text>' || currentFilePath === '<input>') {
return {}
}
const currentDirectory = path.dirname(currentFilePath);
function canResolve(modulePath) {
try {
require.resolve(path.resolve(currentDirectory, modulePath));
return true;
} catch (e) {
const absolutePath = path.resolve(currentDirectory, modulePath);
const extensions = ['', '.js', '.mjs', '.ts', '.jsx', '.tsx', '.json', '.node', '/index.js', '/index.ts']; // Add common extensions
for (const ext of extensions) {
if (fs.existsSync(absolutePath + ext)) {
return true
}
}
return false
}
}
return {
CallExpression(node) {
if (
node.callee.type === 'MemberExpression' &&
node.callee.object.type === 'Identifier' &&
node.callee.object.name === 'vi' &&
node.callee.property.type === 'Identifier' &&
node.callee.property.name === 'doMock'
) {
if (node.arguments.length === 0) {
context.report({
node,
messageId: 'noArguments',
})
return
}
const firstArg = node.arguments[0]
let pathValue = firstArg.value
if (firstArg.type !== 'Literal' || typeof firstArg.value !== 'string') {
if (firstArg.type === 'Identifier') {
const variable = context.getScope().variables.find(v => v.name === firstArg.name);
if (
variable &&
variable.defs.length > 0 &&
variable.defs[0].node.init &&
variable.defs[0].node.init.type === 'Literal' &&
typeof variable.defs[0].node.init.value === 'string'
) {
pathValue = variable.defs[0].node.init.value
if (canResolve(pathValue)) {
return
}
// If the first argument was a variable that didn't resolve then we can't auto-fix it
}
}
context.report({
node: firstArg,
messageId: 'notAStringLiteral',
})
return
}
if (!pathValue.startsWith('.')) {
return
}
if (!canResolve(pathValue)) {
const mjsPath = pathValue.replace('.js', '.mjs')
const additionalReportOptions = {}
if (canResolve(mjsPath)) {
additionalReportOptions.fix = (fixer) => fixer.replaceText(firstArg, `'${mjsPath}'`)
additionalReportOptions.suggest = [
{
desc: `Replace with "${pathValue.replace('.js', '.mjs')}"`,
fix: (fixer) => fixer.replaceText(firstArg, `'${mjsPath}'`),
}
]
}
context.report({
node: firstArg,
messageId: 'unresolvablePath',
data: {
pathValue,
},
...additionalReportOptions
})
}
}
},
}
},
};

View File

@@ -0,0 +1,161 @@
const { RuleTester } = require('eslint')
const preferKebabUrl = require('./prefer-kebab-url')
const noUnnecessaryTrans = require('./no-unnecessary-trans')
const shouldUnescapeTrans = require('./should-unescape-trans')
const noGeneratedEditorThemes = require('./no-generated-editor-themes')
const viDoMockValidPath = require('./require-vi-doMock-valid-path')
const ruleTester = new RuleTester({
parser: require.resolve('@typescript-eslint/parser'),
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
},
})
ruleTester.run('prefer-kebab-url', preferKebabUrl, {
valid: [
{ code: `app.get('/foo-bar')` },
{ code: `app.get('/foo-bar/:id')` },
{ code: `router.post('/foo-bar')` },
{ code: `router.get('/foo-bar/:id/:name/:age')` },
{ code: `webRouter.get('/foo-bar/:user_id/(ProjectName)/get-info')` },
{ code: `webApp.post('/foo-bar/:user_id/(ProjectName)/get-info')` },
{
code: `router.get(/^\\/download\\/project\\/([^/]*)\\/output\\/output\\.pdf$/)`,
},
{
code: `webRouter.get(/^\\/project\\/([^/]*)\\/user\\/([0-9a-f]+)\\/build\\/([0-9a-f-]+)\\/output\\/(.*)$/)`,
},
],
invalid: [
{
code: `app.get('/fooBar')`,
errors: [{ message: 'Route path should be in kebab-case.' }],
},
{
code: `app.get('/fooBar/:id')`,
errors: [{ message: 'Route path should be in kebab-case.' }],
},
{
code: `webRouter.get('/foo_bar/:id/FooBar/:name/fooBar')`,
errors: [{ message: 'Route path should be in kebab-case.' }],
},
{
code: `router.get(/^\\/downLoad\\/pro-ject\\/([^/]*)\\/OutPut\\/out-put\\.pdf$/)`,
errors: [{ message: 'Route path should be in kebab-case.' }],
},
],
})
ruleTester.run('no-unnecessary-trans', noUnnecessaryTrans, {
valid: [
{ code: `<Trans i18nKey="test" components={{ strong: <strong/> }}/>` },
],
invalid: [
{
code: `<Trans i18nKey="test" values={{ test: 'foo '}}/>`,
errors: [{ message: `Use t('…') when there are no components` }],
},
{
code: `<Trans i18nKey="test" />`,
errors: [{ message: `Use t('…') when there are no components` }],
output: `{t('test')}`,
},
],
})
ruleTester.run('should-unescape-trans', shouldUnescapeTrans, {
valid: [
{
code: `<Trans i18nKey="test" components={{ strong: <strong/> }}/>`,
},
{
code: `<Trans i18nKey="test" values={{ foo: 'bar' }} components={{ strong: <strong/> }} shouldUnescape tOptions={{ interpolation: { escapeValue: true } }}/>`,
},
],
invalid: [
{
code: `<Trans i18nKey="test" values={{ foo: 'bar' }} components={{ strong: <strong/> }} />`,
errors: [{ message: 'Trans with values must have shouldUnescape' }],
output: `<Trans i18nKey="test" values={{ foo: 'bar' }}\nshouldUnescape components={{ strong: <strong/> }} />`,
},
{
code: `<Trans i18nKey="test" values={{ foo: 'bar' }} components={{ strong: <strong/> }} shouldUnescape />`,
errors: [
{
message:
'Trans with shouldUnescape must have tOptions.interpolation.escapeValue',
},
],
output: `<Trans i18nKey="test" values={{ foo: 'bar' }} components={{ strong: <strong/> }} shouldUnescape\ntOptions={{ interpolation: { escapeValue: true } }} />`,
},
],
})
const noGeneratedEditorThemesError =
'EditorView.theme and EditorView.baseTheme each add CSS to the page for every instance of the theme. Store the theme in a variable and reuse it instead.'
ruleTester.run('no-generated-editor-themes', noGeneratedEditorThemes, {
valid: [
{
code: `EditorView.theme({ '.cm-editor': { color: 'black' } })`,
},
{
code: `const theme = EditorView.theme({ '.cm-editor': { color: 'black' } })`,
},
],
invalid: [
{
code: `function createTheme() { return EditorView.theme({ '.cm-editor': { color: 'black' } }) }`,
errors: [
{
message: noGeneratedEditorThemesError,
},
],
},
{
code: `() => EditorView.theme({ '.cm-editor': { color: 'black' } })`,
errors: [
{
message: noGeneratedEditorThemesError,
},
],
},
{
code: `class Foo { createTheme() { return EditorView.theme({ '.cm-editor': { color: 'black' } }) } }`,
errors: [
{
message: noGeneratedEditorThemesError,
},
],
},
],
})
ruleTester.run('domock-require-valid-path', viDoMockValidPath, {
valid: [
{
code: 'vi.doMock("./require-vi-doMock-valid-path.js")',
filename: __filename
},
{
code: 'const filename = "./require-vi-doMock-valid-path.js"; vi.doMock(filename);',
filename: __filename
}
],
invalid: [{
code: "vi.doMock('./require-vi-doMock-valid-path2')",
filename: __filename,
errors: [
{
message: 'The path "./require-vi-doMock-valid-path2" in vi.doMock() cannot be resolved relative to the current file.'}
]
}, {
code: 'const filename = "./require-vi-doMock-valid-path2.js"; vi.doMock(filename);',
filename: __filename,
errors: [
{
message: 'The first argument of vi.doMock() must be (or resolve to) a string literal representing a path.'}
]
}]
})

View File

@@ -0,0 +1,60 @@
module.exports = {
meta: {
type: 'problem',
fixable: 'code',
docs: {
description: 'Ensure that Trans with values has shouldUnescape',
},
},
create(context) {
return {
'JSXOpeningElement[name.name="Trans"]'(node) {
const attributes = new Map(
node.attributes.map(attr => [attr.name.name, attr])
)
if (attributes.has('values') && !attributes.has('shouldUnescape')) {
context.report({
node,
message: 'Trans with values must have shouldUnescape',
fix(fixer) {
return fixer.insertTextAfter(
attributes.get('values'),
'\nshouldUnescape' // Note: Prettier can fix indentation
)
},
})
}
if (attributes.has('values') && attributes.has('shouldUnescape')) {
const tOptions = attributes.get('tOptions')
if (!tOptions) {
context.report({
node,
message:
'Trans with shouldUnescape must have tOptions.interpolation.escapeValue',
fix(fixer) {
return fixer.insertTextAfter(
attributes.get('shouldUnescape'),
'\ntOptions={{ interpolation: { escapeValue: true } }}' // Note: Prettier can fix indentation
)
},
})
} else {
const property = tOptions.value.expression.properties
.find(p => p.key.name === 'interpolation')
?.value.properties.find(p => p.key.name === 'escapeValue')
if (property?.value.value !== true) {
context.report({
node,
message:
'Trans with shouldUnescape must have tOptions.interpolation.escapeValue set to true',
})
}
}
}
},
}
},
}

View File

@@ -0,0 +1,6 @@
{
"extends": "../../tsconfig.backend.json",
"include": [
"**/*.js",
]
}

View File

@@ -160,4 +160,21 @@ module.exports = class AbstractPersistor {
name,
})
}
/**
* List objects in a directory, returning the full keys.
*
* Suitable only for directories where the number of keys is known to be small.
*
* @param {string} location
* @param {string} prefix
* @returns {Promise<Array<string>>}
*/
async listDirectoryKeys(location, prefix) {
throw new NotImplementedError('method not implemented in persistor', {
method: 'listDirectoryKeys',
location,
prefix,
})
}
}

View File

@@ -194,6 +194,11 @@ module.exports = class FSPersistor extends AbstractPersistor {
}
}
async listDirectoryKeys(location, name) {
const fsPath = this._getFsPath(location, name)
return await this._listDirectory(fsPath)
}
async checkIfObjectExists(location, name) {
const fsPath = this._getFsPath(location, name)
try {

View File

@@ -288,23 +288,25 @@ module.exports = class GcsPersistor extends AbstractPersistor {
} while (query)
}
async directorySize(bucketName, key) {
let files
const prefix = ensurePrefixIsDirectory(key)
async #listDirectory(bucketName, prefix) {
try {
const [response] = await this.storage
.bucket(bucketName)
.getFiles({ prefix })
files = response
return response
} catch (err) {
throw PersistorHelper.wrapError(
err,
'failed to list objects in GCS',
{ bucketName, key },
{ bucketName, prefix },
ReadError
)
}
}
async directorySize(bucketName, key) {
const prefix = ensurePrefixIsDirectory(key)
const files = await this.#listDirectory(bucketName, prefix)
return files.reduce(
(acc, file) => parseInt(file.metadata.size, 10) + acc,
@@ -312,6 +314,14 @@ module.exports = class GcsPersistor extends AbstractPersistor {
)
}
async listDirectoryKeys(bucketName, prefix) {
const files = await this.#listDirectory(
bucketName,
ensurePrefixIsDirectory(prefix)
)
return files.map(file => file.name)
}
async checkIfObjectExists(bucketName, key) {
try {
const [response] = await this.storage

165
package-lock.json generated
View File

@@ -788,14 +788,14 @@
"license": "MIT"
},
"node_modules/@ai-sdk/gateway": {
"version": "2.0.0-beta.52",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.0-beta.52.tgz",
"integrity": "sha512-xH1J+Fn7sLjDQVB2XPMsha/gCWUhJ+DvNvLzhbNf7P1XdTRsHY3HMx3xnZLXfaHBXOVCJ+JtAtxS3+3xGa2u2A==",
"version": "2.0.0-beta.61",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.0-beta.61.tgz",
"integrity": "sha512-VZ8K1GUFYsFpDe4hz/OJSlPe0EbYMvmtS7ps1ENsB150R17iwnOiW7s37u7vXW52+XwbEiGsObjKgIt4MB1tqw==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.0-beta.16",
"@ai-sdk/provider-utils": "4.0.0-beta.33",
"@vercel/oidc": "3.0.3"
"@ai-sdk/provider": "3.0.0-beta.17",
"@ai-sdk/provider-utils": "4.0.0-beta.34",
"@vercel/oidc": "3.0.5"
},
"engines": {
"node": ">=18"
@@ -805,13 +805,13 @@
}
},
"node_modules/@ai-sdk/mcp": {
"version": "1.0.0-beta.15",
"resolved": "https://registry.npmjs.org/@ai-sdk/mcp/-/mcp-1.0.0-beta.15.tgz",
"integrity": "sha512-/LisjXCTmTT/OIzTV+cLEqxIEBDyJBeEvnzgnrMLY/Vb0ckulp8bOhHwrC9k2DtXgLfpS7qWLd9Jyes0+eMdEA==",
"version": "1.0.0-beta.16",
"resolved": "https://registry.npmjs.org/@ai-sdk/mcp/-/mcp-1.0.0-beta.16.tgz",
"integrity": "sha512-XsaB1yIeUoqot4v3D4uMbMHVzNxbhVvnNjyE8QUnb6UsTzEwf0MkYsK7wMAyzQaC3R3GVmNB0oinuFoottRITQ==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.0-beta.16",
"@ai-sdk/provider-utils": "4.0.0-beta.33",
"@ai-sdk/provider": "3.0.0-beta.17",
"@ai-sdk/provider-utils": "4.0.0-beta.34",
"pkce-challenge": "^5.0.0"
},
"engines": {
@@ -822,13 +822,13 @@
}
},
"node_modules/@ai-sdk/openai": {
"version": "3.0.0-beta.59",
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.0-beta.59.tgz",
"integrity": "sha512-gq3Xo03LAMFllf7ibip+TuhV+TmGrgF+gxeNYTck9YES1q5Vbe1uNZ0uf2ZemqeiOE+9V/4K4NiE8Q99ACpA1Q==",
"version": "3.0.0-beta.64",
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.0-beta.64.tgz",
"integrity": "sha512-UHW/jOmkiiKmgiSqf5zsDqlP6a3gRwzpZX/ptIr0Q9AIAhj4bIYxJr91DgghaRAmL97lgs8XA2ke3SWSeEpFaA==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.0-beta.16",
"@ai-sdk/provider-utils": "4.0.0-beta.33"
"@ai-sdk/provider": "3.0.0-beta.17",
"@ai-sdk/provider-utils": "4.0.0-beta.34"
},
"engines": {
"node": ">=18"
@@ -838,9 +838,9 @@
}
},
"node_modules/@ai-sdk/provider": {
"version": "3.0.0-beta.16",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.0-beta.16.tgz",
"integrity": "sha512-R62Z0fziX467Eu6MtVhkmHm0VFtJrq4vPGo8w4mcc4LhSPncHwn+b9yoyxv3f2pkWyUAhPR4ttgWyZoFG/lXIA==",
"version": "3.0.0-beta.17",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.0-beta.17.tgz",
"integrity": "sha512-1Jek+B4W/8KV48Lcsnl7QBXD22fmwDISz//JRGtPRjexrwr2bSeFJ3yqWpvQo6CYttac9GYf3MTiho4iSl+V2A==",
"license": "Apache-2.0",
"dependencies": {
"json-schema": "^0.4.0"
@@ -850,12 +850,12 @@
}
},
"node_modules/@ai-sdk/provider-utils": {
"version": "4.0.0-beta.33",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.0-beta.33.tgz",
"integrity": "sha512-8UnWOiWP5Fm0X+tU0ne4X3OnbDq5mMMxympHcW5VjVmx+Mc1TgD8KGPl0XsS9l3f61qPSVn1vZC3FthRnTlvhA==",
"version": "4.0.0-beta.34",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.0-beta.34.tgz",
"integrity": "sha512-GxLNX8xZlf2BPJaXsR39ignBVfHthBsXVqRN+S7iCx503IdJXi+mrgWQElBRbpqHucOR+3CQ6CIk8SDaCeGb/A==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.0-beta.16",
"@ai-sdk/provider": "3.0.0-beta.17",
"@standard-schema/spec": "^1.0.0",
"eventsource-parser": "^3.0.6"
},
@@ -890,14 +890,14 @@
}
},
"node_modules/@ai-sdk/react": {
"version": "3.0.0-beta.99",
"resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-3.0.0-beta.99.tgz",
"integrity": "sha512-adFSYMZOE+vUmwlebfBVgQu87eOgNVayyD6sx8jRJwVnyMYJc+QRvfDZBsk1+5xyUfMeOlpJZTo3KhkMTZixGw==",
"version": "3.0.0-beta.111",
"resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-3.0.0-beta.111.tgz",
"integrity": "sha512-grtIYbegCuWI1BN2D8aooG3k13p5M/0NQSdIF7p2xRmdKDTrwcFaf3BKvdX2rNY1SO+G6BNejewatbzLadRdag==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider-utils": "4.0.0-beta.33",
"ai": "6.0.0-beta.99",
"@ai-sdk/provider-utils": "4.0.0-beta.34",
"ai": "6.0.0-beta.111",
"swr": "^2.2.5",
"throttleit": "2.1.0"
},
@@ -21824,21 +21824,10 @@
"preact": "^10.5.13"
}
},
"node_modules/@valibot/to-json-schema": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@valibot/to-json-schema/-/to-json-schema-1.3.0.tgz",
"integrity": "sha512-82Vv6x7sOYhv5YmTRgSppSqj1nn2pMCk5BqCMGWYp0V/fq+qirrbGncqZAtZ09/lrO40ne/7z8ejwE728aVreg==",
"license": "MIT",
"optional": true,
"peer": true,
"peerDependencies": {
"valibot": "^1.1.0"
}
},
"node_modules/@vercel/oidc": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.0.3.tgz",
"integrity": "sha512-yNEQvPcVrK9sIe637+I0jD6leluPxzwJKx/Haw6F4H77CdDsszUn5V3o96LPziXkSNE2B83+Z3mjqGKBK/R6Gg==",
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.0.5.tgz",
"integrity": "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==",
"license": "Apache-2.0",
"engines": {
"node": ">= 20"
@@ -22815,14 +22804,14 @@
}
},
"node_modules/ai": {
"version": "6.0.0-beta.99",
"resolved": "https://registry.npmjs.org/ai/-/ai-6.0.0-beta.99.tgz",
"integrity": "sha512-Z/TQByUwZepN2425FepmvDNNpqVufNtqv1QJju6tGL8uiWLwHc9HKN36+joSuCozeZq9JE77FaQ0TBuRh6Hh/A==",
"version": "6.0.0-beta.111",
"resolved": "https://registry.npmjs.org/ai/-/ai-6.0.0-beta.111.tgz",
"integrity": "sha512-tNNGbqfH7t4RnNpM9znKODXl6o2Pqk3I+M5iPysHlp7jnEBBtzsohk2rCPE6LTKRmHL9UFrbt5Z09Ks8j7iR9A==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/gateway": "2.0.0-beta.52",
"@ai-sdk/provider": "3.0.0-beta.16",
"@ai-sdk/provider-utils": "4.0.0-beta.33",
"@ai-sdk/gateway": "2.0.0-beta.61",
"@ai-sdk/provider": "3.0.0-beta.17",
"@ai-sdk/provider-utils": "4.0.0-beta.34",
"@opentelemetry/api": "1.9.0"
},
"engines": {
@@ -28475,18 +28464,6 @@
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0="
},
"node_modules/effect": {
"version": "3.19.0",
"resolved": "https://registry.npmjs.org/effect/-/effect-3.19.0.tgz",
"integrity": "sha512-eFvvryWkbXvQ4Gak1Nadv9CW6U35+UUS/fIkF4c/Th8rs2u47g+tNkViYeVGliglNnR6Ai5Otl9tLbav3yZjXg==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"fast-check": "^3.23.1"
}
},
"node_modules/ejs": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
@@ -30811,30 +30788,6 @@
"node": ">=18"
}
},
"node_modules/fast-check": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
"integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"pure-rand": "^6.1.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/fast-content-type-parse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-2.0.1.tgz",
@@ -43819,24 +43772,6 @@
"node": ">=6"
}
},
"node_modules/pure-rand": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/qrcode": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.0.tgz",
@@ -50447,7 +50382,7 @@
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -51233,22 +51168,6 @@
"node": ">=10.12.0"
}
},
"node_modules/valibot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.1.0.tgz",
"integrity": "sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==",
"license": "MIT",
"optional": true,
"peer": true,
"peerDependencies": {
"typescript": ">=5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/valid-data-url": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/valid-data-url/-/valid-data-url-2.0.0.tgz",
@@ -55689,8 +55608,8 @@
"services/web": {
"name": "@overleaf/web",
"dependencies": {
"@ai-sdk/mcp": "^1.0.0-beta.15",
"@ai-sdk/openai": "^3.0.0-beta.59",
"@ai-sdk/mcp": "^1.0.0-beta.16",
"@ai-sdk/openai": "^3.0.0-beta.64",
"@aws-sdk/client-ses": "^3.864.0",
"@contentful/rich-text-html-renderer": "^16.0.2",
"@contentful/rich-text-types": "^16.0.2",
@@ -55718,7 +55637,7 @@
"@stripe/stripe-js": "^7.7.0",
"@xmldom/xmldom": "^0.7.13",
"accepts": "^1.3.7",
"ai": "^6.0.0-beta.99",
"ai": "^6.0.0-beta.111",
"ajv": "^8.12.0",
"archiver": "^5.3.0",
"async": "^3.2.5",
@@ -55807,7 +55726,7 @@
"zod-validation-error": "^4.0.1"
},
"devDependencies": {
"@ai-sdk/react": "^3.0.0-beta.99",
"@ai-sdk/react": "^3.0.0-beta.111",
"@babel/cli": "^7.27.0",
"@babel/core": "^7.26.10",
"@babel/plugin-proposal-decorators": "^7.27.0",

View File

@@ -25,13 +25,9 @@ describe('Accounts', function () {
cy.get('@url').then(url => {
cy.visit(`${url}`)
cy.url().should('contain', '/user/activate')
cy.findByText('Please set a password')
cy.get('input[autocomplete="username"]').should(
'have.attr',
'value',
email
)
cy.get('input[name="password"]')
cy.findByRole('heading', { name: 'Please set a password' })
cy.findByLabelText('Email').should('be.visible')
cy.findByLabelText('Password').should('be.visible')
cy.findByRole('button', { name: 'Activate' })
})
})

View File

@@ -16,12 +16,10 @@ describe('admin panel', function () {
const user = `${uuid()}@example.com`
cy.findByLabelText('Emails to register new users').type(user + '{enter}')
cy.get('td')
.contains(/\/user\/activate/)
.then($td => {
const url = $td.text().trim()
activateUser(url)
})
cy.findByRole('cell', { name: /\/user\/activate/ }).then($td => {
const url = $td.text().trim()
activateUser(url)
})
})
it('via GUI and email', function () {
@@ -29,17 +27,15 @@ describe('admin panel', function () {
cy.findByLabelText('Emails to register new users').type(user + '{enter}')
let url: string
cy.get('td')
.contains(/\/user\/activate/)
.then($td => {
url = $td.text().trim()
})
cy.findByRole('cell', { name: /\/user\/activate/ }).then($td => {
url = $td.text().trim()
})
cy.then(() => {
openEmail(
'Activate your E2E test Account',
(frame, { url }) => {
frame.contains('Set password').then(el => {
frame.contains('a', 'Set password').then(el => {
expect(el.attr('href')!).to.equal(url)
})
},
@@ -69,7 +65,7 @@ describe('admin panel', function () {
openEmail(
'Activate your E2E test Account',
(frame, { url }) => {
frame.contains('Set password').then(el => {
frame.contains('a', 'Set password').then(el => {
expect(el.attr('href')!).to.equal(url)
})
},
@@ -145,10 +141,10 @@ describe('admin panel', function () {
const menuitems = ['Manage Site', 'Manage Users', 'Project URL Lookup']
menuitems.forEach(name => {
cy.findByRole('menuitem', { name: 'Admin' }).click()
cy.get('ul[role="menu"]')
cy.findByRole('menu')
.findAllByRole('menuitem')
.should('have.length', menuitems.length)
cy.get('ul[role="menu"]').findByRole('menuitem', { name }).click()
cy.findByRole('menu').findByRole('menuitem', { name }).click()
})
})
})
@@ -206,7 +202,7 @@ describe('admin panel', function () {
})
it('license usage tab', function () {
cy.get('a').contains('License Usage').click()
cy.findByRole('tab', { name: 'License Usage' }).click()
cy.findByText(
'An active user is one who has opened a project in this Server Pro instance in the last 12 months.'
)
@@ -214,7 +210,7 @@ describe('admin panel', function () {
describe('create users', function () {
beforeEach(function () {
cy.get('a').contains('New User').click()
cy.findByRole('link', { name: 'New User' }).click()
})
registrationTests()
})
@@ -263,8 +259,8 @@ describe('admin panel', function () {
it('displays required sections', function () {
// not exhaustive list, checks the tab content is rendered
cy.findByText('Profile')
cy.findByText('Editor Settings')
cy.findByRole('heading', { name: 'Profile' })
cy.findByRole('heading', { name: 'Editor Settings' })
})
it('should not display SaaS-only sections', function () {
@@ -290,9 +286,12 @@ describe('admin panel', function () {
cy.findByRole('tablist').within(() => {
cy.findByRole('tab', { name: 'Projects' }).click()
})
cy.get(`a[href="/admin/project/${testProjectId}"]`)
.should('contain.text', 'Project information')
.click()
cy.findByRole('link', { name: testProjectName })
.parent()
.parent()
.within(() => {
cy.findByRole('link', { name: 'Project information' }).click()
})
cy.findByRole('button', { name: 'Transfer Ownership' }).click()
cy.findByRole('dialog').within(() => {
@@ -319,10 +318,12 @@ describe('admin panel', function () {
cy.findByRole('tablist').within(() => {
cy.findByRole('tab', { name: 'Projects' }).click()
})
cy.get(`a[href="/admin/project/${testProjectId}"]`).should(
'contain.text',
'Project information'
)
cy.findByRole('link', { name: testProjectName })
.parent()
.parent()
.within(() => {
cy.findByRole('link', { name: 'Project information' }).click()
})
})
})

View File

@@ -123,7 +123,9 @@ describe('Project creation and compilation', function () {
cy.findByRole('button', { name: 'Share' }).click()
})
cy.findByRole('dialog').within(() => {
cy.findByTestId('collaborator-email-input').type(COLLABORATOR + ',')
cy.findByRole('combobox', { name: 'Add email address' }).type(
COLLABORATOR + ','
)
cy.findByRole('button', { name: 'Invite' }).click()
cy.findByText('Invite not yet accepted.')
})

View File

@@ -8,7 +8,9 @@ describe('Customization', function () {
it('should display the default right footer', function () {
cy.visit('/')
cy.get('footer').findByRole('link', { name: 'Fork on GitHub!' })
cy.findByRole('contentinfo').findByRole('link', {
name: 'Fork on GitHub!',
})
})
})
@@ -25,16 +27,18 @@ describe('Customization', function () {
it('should display custom name', function () {
cy.visit('/')
cy.get('nav').findByText('CUSTOM APP NAME')
cy.findByRole('navigation', { name: 'Primary' }).findByText(
'CUSTOM APP NAME'
)
})
it('should display custom left footer', function () {
cy.visit('/')
cy.get('footer').findByText('CUSTOM LEFT FOOTER')
cy.findByRole('contentinfo').findByText('CUSTOM LEFT FOOTER')
})
it('should display custom right footer', function () {
cy.visit('/')
cy.get('footer').findByText('CUSTOM RIGHT FOOTER')
cy.findByRole('contentinfo').findByText('CUSTOM RIGHT FOOTER')
})
})
})

View File

@@ -24,7 +24,7 @@ describe('SAML', function () {
it('login', function () {
cy.visit('/')
cy.findByText('Log in with SAML Test Server').click()
cy.findByRole('link', { name: 'Log in with SAML Test Server' }).click()
cy.origin(samlURL, () => {
cy.get('input[name="username"]').type('sally')
@@ -59,11 +59,11 @@ describe('LDAP', function () {
it('login', function () {
cy.visit('/')
cy.findByText('Log in LDAP')
cy.findByRole('heading', { name: 'Log in LDAP' })
cy.get('input[name="login"]').type('fry')
cy.get('input[name="password"]').type('fry')
cy.get('button[type="submit"]').click()
cy.findByLabelText('Username').type('fry')
cy.findByLabelText('Password').type('fry')
cy.findByRole('button', { name: 'Login' }).click()
cy.log('wait for login to finish')
cy.url().should('contain', '/project')

View File

@@ -72,11 +72,13 @@ describe('git-bridge', function () {
cy.findByRole('button', {
name: 'Git integration Generate token',
}).click()
cy.findByLabelText('Git authentication token')
.contains(/olp_[a-zA-Z0-9]{16}/)
.then(el => el.text())
.as('newToken')
cy.findAllByText('Close').last().click()
cy.findByRole('dialog').within(() => {
cy.findByLabelText('Git authentication token')
.contains(/olp_[a-zA-Z0-9]{16}/)
.then(el => el.text())
.as('newToken')
cy.findByRole('button', { name: 'Close dialog' }).click()
})
cy.get('@newToken').then(token => {
// There can be more than one token with the same prefix when retrying
cy.findAllByText(
@@ -258,7 +260,7 @@ describe('git-bridge', function () {
const token = tokenEl.text()
// close Git modal
cy.get('body').type('{esc}')
cy.findByRole('button', { name: 'Close dialog' }).click()
cy.findByTestId('git-bridge-modal').should('not.exist')
// close the modal
cy.get('body').type('{esc}')
@@ -345,8 +347,16 @@ Hello world
})
.findByRole('button', { name: 'History' })
.click()
cy.findByText('(via Git)').should('not.exist')
cy.findAllByText('Back to editor').last().click()
cy.findByRole('complementary', {
name: 'Project history and labels',
}).within(() => {
cy.findByText('(via Git)').should('not.exist')
})
cy.findByRole('navigation', {
name: 'Project actions',
})
.findByRole('button', { name: 'Back to editor' })
.click()
cy.then(async () => {
await git.push({
...commonOptions,
@@ -379,10 +389,10 @@ Hello world
// Wait for history sync - trigger flush by toggling the UI
cy.findByRole('navigation', {
name: 'Project actions',
}).within(() => {
cy.findByRole('button', { name: 'History' }).click()
cy.findByRole('button', { name: 'Back to editor' }).click()
})
.findByRole('button', { name: 'History' })
.click()
cy.findAllByText('Back to editor').last().click()
// check push in history
cy.findByRole('navigation', {
@@ -391,10 +401,18 @@ Hello world
.findByRole('button', { name: 'History' })
.click()
cy.findByText(/Hello world/)
cy.findByText('(via Git)').should('exist')
cy.findByRole('complementary', {
name: 'Project history and labels',
}).within(() => {
cy.findByText('(via Git)').should('exist')
})
// Back to the editor
cy.findAllByText('Back to editor').last().click()
cy.findByRole('navigation', {
name: 'Project actions',
})
.findByRole('button', { name: 'Back to editor' })
.click()
cy.findByText(/\\documentclass/)
.parent()
.parent()

View File

@@ -39,14 +39,19 @@ describe('GracefulShutdown', function () {
})
cy.log('add additional content')
cy.findByText('\\maketitle').parent().click()
cy.findByText('\\maketitle').parent().type(`\n\\section{{}New Section}`)
cy.findByRole('region', { name: 'Editor' }).within(() => {
cy.findByText('\\maketitle').parent().click()
cy.findByText('\\maketitle').parent().type(`\n\\section{{}New Section}`)
})
recompile()
cy.log(
'check flush from frontend to backend: should include new section in PDF'
)
cy.get('.pdf-viewer').should('contain.text', 'New Section')
cy.findByRole('region', { name: 'PDF preview and logs' }).should(
'contain.text',
'New Section'
)
cy.log('should have unflushed content in redis before shutdown')
cy.then(async () => {
@@ -62,8 +67,9 @@ describe('GracefulShutdown', function () {
})
cy.log('wait for banner')
cy.findByText(/performing maintenance/)
cy.findByRole('dialog').findByText(/performing maintenance/)
cy.log('wait for page reload')
cy.findByRole('heading', { name: 'Maintenance' })
cy.findByText(/is currently down for maintenance/)
cy.log('wait for shutdown to complete')
@@ -85,13 +91,19 @@ describe('GracefulShutdown', function () {
})
cy.log('check loading doc from mongo')
cy.findByText('New Section')
cy.findByRole('region', { name: 'Editor' }).findByText('New Section')
cy.log('check PDF')
cy.get('.pdf-viewer').should('contain.text', 'New Section')
cy.findByRole('region', { name: 'PDF preview and logs' }).should(
'contain.text',
'New Section'
)
cy.log('check history')
cy.findByText('History').click()
cy.findByRole('navigation', {
name: 'Project actions',
})
.findByRole('button', { name: 'History' })
.click()
cy.findByText(/\\section\{New Section}/)
})
})

View File

@@ -26,22 +26,19 @@ describe('LearnWiki', function () {
it('should add a documentation entry to the nav bar', function () {
login(REGULAR_USER)
cy.visit('/project')
cy.findByRole('menuitem', { name: 'Documentation' }).should(
'have.attr',
'href',
'/learn'
)
cy.findByRole('navigation', { name: 'Primary' }).findByRole('menuitem', {
name: 'Documentation',
})
})
it('should display a tutorial link in the welcome page', function () {
login(WITHOUT_PROJECTS_USER)
cy.visit('/project')
cy.findByRole('link', { name: LABEL_LEARN_LATEX })
.should('have.attr', 'href', '/learn/latex/Learn_LaTeX_in_30_minutes')
.and('have.attr', 'target', '_blank')
.within(() => {
cy.get('img').should('have.attr', 'src').and('not.be.empty')
})
cy.findByRole('link', { name: LABEL_LEARN_LATEX }).should(
'have.attr',
'href',
'/learn/latex/Learn_LaTeX_in_30_minutes'
)
})
it('should render wiki page', function () {
@@ -102,7 +99,11 @@ describe('LearnWiki', function () {
it('should not add a documentation entry to the nav bar', function () {
login(REGULAR_USER)
cy.visit('/project')
cy.findByText('Documentation').should('not.exist')
cy.findByRole('navigation', { name: 'Primary' })
.findByRole('menuitem', {
name: 'Documentation',
})
.should('not.exist')
})
it('should not render wiki page', function () {
@@ -110,7 +111,7 @@ describe('LearnWiki', function () {
cy.visit(COPYING_A_PROJECT_URL, {
failOnStatusCode: false,
})
cy.findByText('Not found')
cy.findByRole('heading', { name: 'Not found' })
})
it('should not display a tutorial link in the welcome page', function () {

View File

@@ -50,10 +50,12 @@ describe('Project Sharing', function () {
// Add chat message
cy.findByRole('button', { name: 'Chat' }).click()
// wait for lazy loading of the chat pane
cy.findByText('Send your first message to your collaborators')
cy.get(
'textarea[placeholder="Send a message to your collaborators…"]'
).type('New Chat Message{enter}')
cy.findByRole('complementary', { name: 'Chat' }).findByText(
'Send your first message to your collaborators'
)
cy.findByLabelText('Send a message to your collaborators…').type(
'New Chat Message{enter}'
)
// Get link sharing links
enableLinkSharing().then(
@@ -130,7 +132,9 @@ describe('Project Sharing', function () {
function expectChatAccess() {
cy.findByRole('button', { name: 'Chat' }).click()
cy.findByText('New Chat Message')
cy.findByRole('complementary', { name: 'Chat' }).findByText(
'New Chat Message'
)
}
function expectHistoryAccess() {
@@ -451,8 +455,14 @@ describe('Project Sharing', function () {
it('should not display link sharing in the sharing modal', function () {
login('user@example.com')
openProjectByName(projectName)
cy.findByText('Share').click()
cy.findByText('Turn on link sharing').should('not.exist')
cy.findByRole('navigation', {
name: 'Project actions',
})
.findByRole('button', { name: 'Share' })
.click()
cy.findByRole('button', { name: 'Turn on link sharing' }).should(
'not.exist'
)
})
it('should block new access to read-only link shared projects', function () {

View File

@@ -38,7 +38,9 @@ describe('SandboxedCompiles', function () {
cy.log('Check which compiler version was used, expect 2023')
cy.findByRole('button', { name: 'View logs' }).click()
cy.findByText(/This is pdfTeX, Version .+ \(TeX Live 2023\) /)
cy.findByLabelText('Raw logs from the LaTeX compiler').findByText(
/This is pdfTeX, Version .+ \(TeX Live 2023\) /
)
cy.log('Switch TeXLive version from 2023 to 2022')
cy.findByRole('navigation', {
@@ -59,7 +61,9 @@ describe('SandboxedCompiles', function () {
cy.log('Check which compiler version was used, expect 2022')
cy.findByRole('button', { name: 'View logs' }).click()
cy.findByText(/This is pdfTeX, Version .+ \(TeX Live 2022\) /)
cy.findByLabelText('Raw logs from the LaTeX compiler').findByText(
/This is pdfTeX, Version .+ \(TeX Live 2022\) /
)
})
checkSyncTeX()
@@ -85,10 +89,10 @@ describe('SandboxedCompiles', function () {
waitForCompileRateLimitCoolOff()
cy.log('Start compile')
// We need to start the compile manually because we do not want to wait for it to finish
cy.findByText('Recompile').click()
cy.findByRole('button', { name: 'Recompile' }).click()
// Now stop the compile and kill the latex process
stopCompile({ delay: 1000 })
cy.get('.logs-pane')
cy.findByRole('region', { name: 'PDF preview and logs' })
.invoke('text')
.should('match', /PDF Rendering Error|Compilation cancelled/)
// Check that the previous compile is not running in the background by
@@ -96,11 +100,9 @@ describe('SandboxedCompiles', function () {
cy.findByText('\\def').parent().click()
cy.findByText('\\def').parent().type('{home}disabled loop% ')
recompile()
cy.get('.pdf-viewer').should('contain.text', 'disabled loop')
cy.get('.logs-pane').should(
'not.contain.text',
'A previous compile is still running'
)
cy.findByRole('region', { name: 'PDF preview and logs' })
.should('contain.text', 'disabled loop')
.should('not.contain.text', 'A previous compile is still running')
})
}
@@ -151,7 +153,10 @@ describe('SandboxedCompiles', function () {
.findByText('Section B')
.scrollIntoView()
cy.get('@start').then((start: any) => {
waitUntilScrollingFinished('.pdfjs-viewer-inner', start)
waitUntilScrollingFinished(
'[data-testid="pdfjs-viewer-inner"]',
start
)
})
// The sync button is swapped as the position in the PDF changes.
// Cypress appears to click on a button that references a stale position.
@@ -166,11 +171,13 @@ describe('SandboxedCompiles', function () {
it('should sync to pdf', function () {
cy.log('zoom in')
cy.findByRole('button', { name: /^\d+%$/ }).click() // TODO: ARIA label
cy.findByRole('button', { name: 'PDF zoom level' }).click()
cy.findByRole('menuitem', { name: '400%' }).click()
cy.log('scroll to top')
cy.findByTestId('pdfjs-viewer-inner').scrollTo('top')
waitUntilScrollingFinished('.pdfjs-viewer-inner', -1).as('start')
waitUntilScrollingFinished('[data-testid="pdfjs-viewer-inner"]', -1).as(
'start'
)
cy.log('navigate to title')
cy.findByRole('textbox', { name: 'Source Editor editing' }).within(
@@ -180,7 +187,10 @@ describe('SandboxedCompiles', function () {
)
cy.findByRole('button', { name: 'Go to code location in PDF' }).click()
cy.get('@start').then((start: any) => {
waitUntilScrollingFinished('.pdfjs-viewer-inner', start)
waitUntilScrollingFinished(
'[data-testid="pdfjs-viewer-inner"]',
start
)
.as('title')
.should('be.greaterThan', start)
})
@@ -191,7 +201,10 @@ describe('SandboxedCompiles', function () {
)
cy.findByRole('button', { name: 'Go to code location in PDF' }).click()
cy.get('@title').then((title: any) => {
waitUntilScrollingFinished('.pdfjs-viewer-inner', title)
waitUntilScrollingFinished(
'[data-testid="pdfjs-viewer-inner"]',
title
)
.as('sectionA')
.should('be.greaterThan', title)
})
@@ -202,7 +215,10 @@ describe('SandboxedCompiles', function () {
)
cy.findByRole('button', { name: 'Go to code location in PDF' }).click()
cy.get('@sectionA').then((title: any) => {
waitUntilScrollingFinished('.pdfjs-viewer-inner', title)
waitUntilScrollingFinished(
'[data-testid="pdfjs-viewer-inner"]',
title
)
.as('sectionB')
.should('be.greaterThan', title)
})
@@ -225,10 +241,9 @@ describe('SandboxedCompiles', function () {
})
recompile()
recompile()
cy.findByRole('region', { name: 'PDF preview and logs' }).within(() => {
cy.findByText('Test Section').should('contain.text', 'Test Section')
})
cy.findByTestId('logs-pane').should('not.contain.text', 'No PDF')
cy.findByRole('region', { name: 'PDF preview and logs' })
.findByText('Test Section')
.should('not.contain.text', 'No PDF')
})
}
@@ -239,13 +254,14 @@ describe('SandboxedCompiles', function () {
createProject('XeLaTeX')
})
cy.log('wait for compile')
cy.findByRole('region', { name: 'PDF preview and logs' }).should(
'contain.text',
cy.findByRole('region', { name: 'PDF preview and logs' }).findByText(
'XeLaTeX'
)
cy.log('Check which compiler was used, expect pdfLaTeX')
cy.findByRole('button', { name: 'View logs' }).click()
cy.findByText(/This is pdfTeX/)
cy.findByLabelText('Raw logs from the LaTeX compiler').findByText(
/This is pdfTeX/
)
cy.log('Switch compiler to from pdfLaTeX to XeLaTeX')
cy.findByRole('navigation', {
@@ -265,7 +281,9 @@ describe('SandboxedCompiles', function () {
cy.log('Check which compiler was used, expect XeLaTeX')
cy.findByRole('button', { name: 'View logs' }).click()
cy.findByText(/This is XeTeX/)
cy.findByLabelText('Raw logs from the LaTeX compiler').findByText(
/This is XeTeX/
)
})
}
@@ -277,11 +295,15 @@ describe('SandboxedCompiles', function () {
it('should not offer TexLive images and use default compiler', function () {
createProject('sandboxed')
cy.log('wait for compile')
cy.get('.pdf-viewer').should('contain.text', 'sandboxed')
cy.findByRole('region', { name: 'PDF preview and logs' }).findByText(
'sandboxed'
)
cy.log('Check which compiler version was used, expect 2025')
cy.get('[aria-label="View logs"]').click()
cy.findByText(/This is pdfTeX, Version .+ \(TeX Live 2025\) /)
cy.findByRole('button', { name: 'View logs' }).click()
cy.findByLabelText('Raw logs from the LaTeX compiler').findByText(
/This is pdfTeX, Version .+ \(TeX Live 2025\) /
)
cy.log('Check that there is no TeX Live version toggle')
cy.findByRole('navigation', {
@@ -289,8 +311,10 @@ describe('SandboxedCompiles', function () {
})
.findByRole('button', { name: 'Menu' })
.click()
cy.findByText('Word Count') // wait for lazy loading
cy.findByText(LABEL_TEX_LIVE_VERSION).should('not.exist')
cy.findByTestId('left-menu').within(() => {
cy.findByRole('button', { name: 'Word Count' }) // wait for lazy loading
cy.findByText(LABEL_TEX_LIVE_VERSION).should('not.exist')
})
})
}

View File

@@ -43,9 +43,7 @@ describe('Templates', function () {
it('should show templates link on welcome page', function () {
login(WITHOUT_PROJECTS_USER)
cy.visit('/')
cy.findByRole('link', { name: LABEL_BROWSE_TEMPLATES })
.should('have.attr', 'href', '/templates')
.click()
cy.findByRole('link', { name: LABEL_BROWSE_TEMPLATES }).click()
cy.url().should('match', /\/templates$/)
})
@@ -62,32 +60,31 @@ describe('Templates', function () {
})
.findByRole('button', { name: 'Menu' })
.click()
cy.findByText('Manage Template').click()
cy.findByRole('button', { name: 'Manage Template' }).click()
cy.findByText('Template Description').as('description').click()
cy.get('@description').parent().get('textarea').type(description)
cy.findByText('Publish').click()
cy.findByText('Publishing…').parent().should('be.disabled')
cy.findByText('Publish').should('not.exist')
cy.findByText('Unpublish', { timeout: 60_000 })
cy.findByText('Republish')
cy.findByLabelText('Template Description').type(description)
cy.findByRole('button', { name: 'Publish' }).click()
cy.findByRole('button', { name: 'Publishing…' }).should('be.disabled')
cy.findByRole('button', { name: 'Publish' }).should('not.exist')
cy.findByRole('button', { name: 'Unpublish', timeout: 60_000 })
cy.findByRole('button', { name: 'Republish' })
cy.findByText('View it in the template gallery').click()
cy.findByRole('link', { name: 'View it in the template gallery' }).click()
cy.url()
.should('match', /\/templates\/[a-f0-9]{24}$/)
.as('templateURL')
cy.findAllByText(name).first().should('exist')
cy.findByRole('heading', { level: 2 }).findByText(name)
cy.findByText(description)
cy.findByText('Open as Template')
cy.findByText('Unpublish')
cy.findByText('Republish')
cy.findByRole('link', { name: 'Open as Template' })
cy.findByRole('button', { name: 'Unpublish' })
cy.findByRole('button', { name: 'Republish' })
cy.get('img')
.should('have.attr', 'src')
.and('match', /\/v\/0\//)
cy.findByText('Republish').click()
cy.findByText('Publishing…').parent().should('be.disabled')
cy.findByText('Republish', { timeout: 60_000 })
cy.findByRole('button', { name: 'Republish' }).click()
cy.findByRole('button', { name: 'Publishing…' }).should('be.disabled')
cy.findByRole('button', { name: 'Republish', timeout: 60_000 })
cy.get('img', { timeout: 60_000 })
.should('have.attr', 'src')
.and('match', /\/v\/1\//)
@@ -95,43 +92,38 @@ describe('Templates', function () {
// custom tag
const tagName = `${Date.now()}`
cy.visit('/')
cy.findByText(name)
.parent()
.parent()
.within(() => cy.get('input[type="checkbox"]').first().check())
cy.get('.project-list-sidebar-scroll').within(() => {
cy.findAllByText('New tag').first().click()
})
cy.findByRole('checkbox', { name: `Select ${name}` }).check()
cy.findByRole('navigation', { name: 'Project categories and tags' })
.findByRole('button', { name: 'New tag' })
.click()
cy.focused().type(tagName)
cy.findByText('Create').click()
cy.get('.project-list-sidebar-scroll').within(() => {
cy.findByText(tagName)
.parent()
.within(() => cy.get('.name').should('have.text', `${tagName} (1)`))
})
cy.findByRole('button', { name: 'Create' }).click()
cy.findByRole('navigation', {
name: 'Project categories and tags',
}).should('contain', `${tagName} (1)`)
// Check listing
cy.visit('/templates')
cy.findByText(tagName)
cy.findByRole('link', { name: tagName })
cy.visit('/templates/all')
cy.findByText(name)
cy.findByRole('heading', { name })
cy.visit(`/templates/${tagName}`)
cy.findByText(name)
cy.findByRole('heading', { name })
// Unpublish via template page
cy.get('@templateURL').then(url => cy.visit(`${url}`))
cy.findByText('Unpublish').click()
cy.findByRole('button', { name: 'Unpublish' }).click()
cy.url().should('match', /\/templates$/)
cy.get('@templateURL').then(url =>
cy.visit(`${url}`, {
failOnStatusCode: false,
})
)
cy.findByText('Not found')
cy.findByRole('heading', { name: 'Not found' })
cy.visit('/templates/all')
cy.findByText(name).should('not.exist')
cy.findByRole('heading', { name }).should('not.exist')
cy.visit(`/templates/${tagName}`)
cy.findByText(name).should('not.exist')
cy.findByRole('heading', { name }).should('not.exist')
// Publish again
cy.get('@templateProjectId').then(projectId =>
@@ -142,12 +134,12 @@ describe('Templates', function () {
})
.findByRole('button', { name: 'Menu' })
.click()
cy.findByText('Manage Template').click()
cy.findByText('Publish').click()
cy.findByText('Unpublish', { timeout: 60_000 })
cy.findByRole('button', { name: 'Manage Template' }).click()
cy.findByRole('button', { name: 'Publish' }).click()
cy.findByRole('button', { name: 'Unpublish', timeout: 60_000 })
// Should assign a new template id
cy.findByText('View it in the template gallery').click()
cy.findByRole('link', { name: 'View it in the template gallery' }).click()
cy.url()
.should('match', /\/templates\/[a-f0-9]{24}$/)
.as('newTemplateURL')
@@ -161,31 +153,32 @@ describe('Templates', function () {
// Open project from template
login(REGULAR_USER)
cy.visit('/templates')
cy.findByText(tagName).click()
cy.findByText(name).click()
cy.findByText('Open as Template').click()
cy.url().should('match', /\/project\/[a-f0-9]{24}$/)
cy.get('.project-name').should('contain.text', 'Your Paper') // might have (1) suffix
cy.findByRole('link', { name: tagName }).click()
cy.findByRole('link', { name }).click()
cy.findByRole('link', { name: 'Open as Template' }).click()
cy.findByRole('navigation', { name: 'Project actions' }).findByText(
/Your Paper/i
) // might have (1) suffix
cy.findByRole('navigation', {
name: 'Project actions',
})
.findByRole('button', { name: 'Menu' })
.click()
cy.findByText('Word Count') // wait for lazy loading
cy.findByText('Manage Template').should('not.exist')
cy.findByRole('button', { name: 'Word Count' }).click() // wait for lazy loading
cy.findByRole('button', { name: 'Manage Template' }).should('not.exist')
// Check management as regular user
cy.get('@newTemplateURL').then(url => cy.visit(`${url}`))
cy.findByText('Open as Template')
cy.findByText('Unpublish').should('not.exist')
cy.findByText('Republish').should('not.exist')
cy.findByRole('link', { name: 'Open as Template' })
cy.findByRole('button', { name: 'Unpublish' }).should('not.exist')
cy.findByRole('button', { name: 'Republish' }).should('not.exist')
// Check management as admin user
login(ADMIN_USER)
cy.get('@newTemplateURL').then(url => cy.visit(`${url}`))
cy.findByText('Open as Template')
cy.findByText('Unpublish')
cy.findByText('Republish')
cy.findByRole('link', { name: 'Open as Template' })
cy.findByRole('button', { name: 'Unpublish' })
cy.findByRole('button', { name: 'Republish' })
cy.get('@templateProjectId').then(projectId =>
cy.visit(`/project/${projectId}`)
)
@@ -194,8 +187,8 @@ describe('Templates', function () {
})
.findByRole('button', { name: 'Menu' })
.click()
cy.findByText('Manage Template').click()
cy.findByText('Unpublish')
cy.findByRole('button', { name: 'Manage Template' }).click()
cy.findByRole('button', { name: 'Unpublish' })
// Back to templates user
login(TEMPLATES_USER)
@@ -209,19 +202,20 @@ describe('Templates', function () {
})
.findByRole('button', { name: 'Menu' })
.click()
cy.findByText('Manage Template').click()
cy.findByText('Unpublish').click()
cy.findByText('Publish')
cy.findByRole('button', { name: 'Manage Template' }).click()
cy.findByRole('button', { name: 'Unpublish' }).click()
cy.findByRole('button', { name: 'Publish' })
cy.visit('/templates/all')
cy.findByText(name).should('not.exist')
cy.findByRole('link', { name }).should('not.exist')
// check for template links, after creating the first project
cy.visit('/')
cy.findAllByRole('button', { name: NEW_PROJECT_BUTTON_MATCHER }).click()
cy.findAllByText('All Templates')
.first()
.parent()
.should('have.attr', 'href', '/templates/all')
cy.findByRole('menuitem', { name: /All Templates/ }).should(
'have.attr',
'href',
'/templates/all'
)
})
})
@@ -237,25 +231,27 @@ describe('Templates', function () {
})
.findByRole('button', { name: 'Menu' })
.click()
cy.findByText('Word Count') // wait for lazy loading
cy.findByText('Manage Template').should('not.exist')
cy.findByRole('button', { name: 'Word Count' }) // wait for lazy loading
cy.findByRole('button', { name: 'Manage Template' }).should('not.exist')
cy.visit('/templates', { failOnStatusCode: false })
cy.findByText('Not found')
cy.findByRole('heading', { name: 'Not found' })
cy.visit('/templates/all', { failOnStatusCode: false })
cy.findByText('Not found')
cy.findByRole('heading', { name: 'Not found' })
// check for template links, after creating the first project
cy.visit('/')
cy.findAllByRole('button', { name: NEW_PROJECT_BUTTON_MATCHER }).click()
cy.findAllByText('All Templates').should('not.exist')
cy.findByRole('menuitem', { name: /All Templates/ }).should('not.exist')
})
it('should not show templates link on welcome page', function () {
login(WITHOUT_PROJECTS_USER)
cy.visit('/')
cy.findByText(NEW_PROJECT_BUTTON_MATCHER) // wait for lazy loading
cy.findByText(LABEL_BROWSE_TEMPLATES).should('not.exist')
cy.findByRole('button', { name: NEW_PROJECT_BUTTON_MATCHER }) // wait for lazy loading
cy.findByRole('link', { name: LABEL_BROWSE_TEMPLATES }).should(
'not.exist'
)
})
}

View File

@@ -49,7 +49,7 @@ describe('Upgrading', function () {
})
cy.log('Wait for successful compile')
cy.get('.pdf-viewer').should('contain.text', PROJECT_NAME)
cy.findByLabelText(/Page.*1/i).findByText(PROJECT_NAME)
cy.log('Increment the doc version three times')
for (let i = 0; i < 3; i++) {
@@ -66,15 +66,15 @@ describe('Upgrading', function () {
})
.findByRole('button', { name: 'Menu' })
.click()
cy.findByText('Source').click()
cy.get('.left-menu-modal-backdrop').click({ force: true })
cy.findByRole('link', { name: 'Source' }).click()
cy.get('body').type('{esc}')
}
cy.log('Check compile and history')
for (let i = 0; i < 3; i++) {
cy.get('.pdf-viewer').should('contain.text', `Old Section ${i}`)
cy.findByLabelText(/Page.*1/i).findByText(`Old Section ${i}`)
}
cy.findByText('History').click()
cy.findByRole('button', { name: 'History' }).click()
for (let i = 0; i < 3; i++) {
cy.findByText(new RegExp(`\\\\section{Old Section ${i}}`))
}
@@ -119,7 +119,7 @@ describe('Upgrading', function () {
it('should list the old project', function () {
cy.visit('/project')
cy.findByText(PROJECT_NAME)
cy.findByRole('link', { name: PROJECT_NAME })
})
it('should open the old project', function () {
@@ -135,8 +135,8 @@ describe('Upgrading', function () {
})
cy.log('wait for successful compile')
cy.get('.pdf-viewer').should('contain.text', PROJECT_NAME)
cy.get('.pdf-viewer').should('contain.text', 'Old Section 2')
cy.findByLabelText(/Page.*1/i).findByText(PROJECT_NAME)
cy.findByLabelText(/Page.*1/i).findByText('Old Section 2')
cy.log('Add more content')
const newSection = `New Section ${uuid()}`
@@ -145,8 +145,8 @@ describe('Upgrading', function () {
cy.log('Check compile and history')
recompile()
cy.get('.pdf-viewer').should('contain.text', newSection)
cy.findByText('History').click()
cy.findByLabelText(/Page.*1/i).findByText(newSection)
cy.findByRole('button', { name: 'History' }).click()
cy.findByText(/\\section\{Old Section 2}/)
cy.findByText(new RegExp(`\\\\section\\{${newSection}}`))
})
@@ -208,10 +208,10 @@ describe('Upgrading', function () {
cy.log('Trigger flush')
recompile()
cy.get('.pdf-viewer').should('contain.text', 'FiveOOne Section')
cy.findByLabelText(/Page.*1/i).findByText('FiveOOne Section')
cy.log('Check for broken history, i.e. not synced with latest edit')
cy.findByText('History').click()
cy.findByRole('button', { name: 'History' }).click()
cy.findByText(/\\section\{Old Section 2}/) // wait for lazy loading
cy.findByText(/\\section\{FiveOOne Section}/).should('not.exist')
})
@@ -246,7 +246,7 @@ describe('Upgrading', function () {
cy.log(
'The edit that was made while the history was broken should be there now.'
)
cy.findByText('History').click()
cy.findByRole('button', { name: 'History' }).click()
cy.findByText(/\\section\{FiveOOne Section}/)
// TODO(das7pad): restore after https://github.com/overleaf/internal/issues/19588 is fixed.

View File

@@ -353,6 +353,7 @@ module.exports = {
'scripts/suspend_users.mjs',
'scripts/sync-user-entitlements/sync-user-entitlements.mjs',
'scripts/update_project_image_name.mjs',
'scripts/user-export/analytics.mjs',
'scripts/user-export/fs.mjs',
'scripts/user-export/http.mjs',
'scripts/user-export/observer.mjs',

View File

@@ -374,6 +374,15 @@ pipeline {
always {
junit checksName: 'Web test results', testResults: 'services/web/data/reports/junit-*.xml,services/web/data/reports/junit-*/**/*.xml'
}
failure {
script {
if (env.BRANCH_NAME == 'main') {
node('built-in') {
sh '/usr/local/bin/open-gh-failure-issue --project="🚉 Platform"'
}
}
}
}
// Ensure tear down of test containers, then run general Jenkins VM cleanup.
cleanup {
dir('services/web') {

View File

@@ -2,11 +2,28 @@ import { callbackifyAll } from '@overleaf/promise-utils'
import UserGetter from '../User/UserGetter.mjs'
import PlansLocator from '../Subscription/PlansLocator.mjs'
import Settings from '@overleaf/settings'
import InstitutionsGetter from './InstitutionsGetter.mjs'
import FeaturesHelper from '../Subscription/FeaturesHelper.mjs'
async function _getInstitutionsAddons(userId) {
const affiliates =
await InstitutionsGetter.promises.getCurrentAffiliations(userId)
// currently only addOn available to institutions is assist/WF bundle,
// which is denoted by the presence of writefullCommonsAccount on the institution
const hasAssistBundle = affiliates.some(
affiliate => affiliate?.institution?.writefullCommonsAccount === true
)
return hasAssistBundle ? { aiErrorAssistant: true } : {}
}
async function getInstitutionsFeatures(userId) {
const planCode = await getInstitutionsPlan(userId)
const plan = planCode && PlansLocator.findLocalPlanInSettings(planCode)
const features = plan && plan.features
let features = plan && plan.features
const addOns = await _getInstitutionsAddons(userId)
features = FeaturesHelper.mergeFeatures(features, addOns)
return features || {}
}

View File

@@ -452,10 +452,11 @@ const _ProjectController = {
'editor-redesign-new-users',
'writefull-frontend-migration',
'chat-edit-delete',
'compile-timeout-remove-info',
'ai-workbench',
'compile-timeout-target-plans',
'writefull-keywords-generator',
'writefull-figure-generator',
'pdf-dark-mode',
].filter(Boolean)
const getUserValues = async userId =>

View File

@@ -48,6 +48,7 @@ async function buildUserSettings(req, res, user) {
breadcrumbs: user.ace.breadcrumbs,
referencesSearchMode: user.ace.referencesSearchMode,
enableNewEditor: user.ace.enableNewEditor ?? defaultEnableNewEditor,
darkModePdf: user.ace.darkModePdf ?? false,
}
}

View File

@@ -16,6 +16,8 @@ import AnalyticsManager from '../Analytics/AnalyticsManager.mjs'
import Queues from '../../infrastructure/Queues.mjs'
import Modules from '../../infrastructure/Modules.js'
import { AI_ADD_ON_CODE } from './AiHelper.mjs'
// import { fetchNothing } from '@overleaf/fetch-utils'
import metrics from '@overleaf/metrics'
/**
* Enqueue a job for refreshing features for the given user
@@ -42,7 +44,7 @@ async function refreshFeatures(userId, reason) {
})
const oldFeatures = _.clone(user.features)
const features = await computeFeatures(userId)
logger.debug({ userId, features }, 'updating user features')
logger.debug({ userId, features, reason }, 'updating user features')
const matchedFeatureSet = FeaturesHelper.getMatchedFeatureSet(features)
AnalyticsManager.setUserPropertyForUserInBackground(
@@ -71,6 +73,33 @@ async function refreshFeatures(userId, reason) {
}
}
// only update Writefull if the user's features have changed,
// skip if they are the reason we are refreshing features (they'd already be up to date)
if (featuresChanged && reason !== 'writefullEntitlementSynced') {
try {
// update WF with the current feature set for the user
// await fetchNothing(
// `${Settings.writefull.overleafApiUrl}/api/user/status/update-overleaf-status`,
// {
// headers: {
// 'x-api-key': Settings.writefull.overleafApiKey,
// },
// json: {
// userOverleafId: userId,
// hasAiAssist: newFeatures.aiErrorAssistant,
// },
// method: 'POST',
// }
// )
// increment a metric instead of calling WF so we cna give them an idea of the # of requests they will recieve
metrics.inc('feature_sync_called_to_wf')
} catch (err) {
logger.warn(
{ userId, reason },
'failed to sync entitlement to Writefull after a feature refresh'
)
}
}
return { features: newFeatures, featuresChanged }
}

View File

@@ -107,7 +107,11 @@ async function findProjectByIdWithRWAccess(userId, projectId) {
for (const projects of [allProjects.owned, allProjects.readAndWrite]) {
for (const project of projects) {
if (project._id.toString() === projectId) {
return project
if (ProjectHelper.isArchivedOrTrashed(project, userId)) {
return null
} else {
return project
}
}
}
}

View File

@@ -40,6 +40,8 @@ const MANAGED_GROUP_USER_EVENTS = [
'unlink-dropbox',
'link-github',
'unlink-github',
'delete-account',
'leave-group-subscription',
]
/**
@@ -100,12 +102,29 @@ async function addEntry(userId, operation, initiatorId, ipAddress, info = {}) {
await UserAuditLogEntry.create(entry)
}
function addEntryInBackground(
userId,
operation,
initiatorId,
ipAddress,
info = {}
) {
// Intentionally not awaited
addEntry(userId, operation, initiatorId, ipAddress, info).catch(err => {
logger.error(
{ err, userId, operation, initiatorId, ipAddress, info },
'error adding user audit log entry'
)
})
}
const UserAuditLogHandler = {
MANAGED_GROUP_USER_EVENTS,
addEntry: callbackify(addEntry),
promises: {
addEntry,
},
addEntryInBackground,
}
export default UserAuditLogHandler

View File

@@ -407,6 +407,9 @@ async function updateUserSettings(req, res, next) {
if (body.enableNewEditor != null) {
user.ace.enableNewEditor = Boolean(body.enableNewEditor)
}
if (body.darkModePdf != null) {
user.ace.darkModePdf = Boolean(body.darkModePdf)
}
await user.save()
const newEmail = body.email?.trim().toLowerCase()
@@ -473,6 +476,16 @@ async function doLogout(req) {
logger.debug({ user }, 'logging out')
const sessionId = req.sessionID
if (user != null) {
UserAuditLogHandler.addEntryInBackground(
user._id,
'logout',
user._id,
req.ip,
{}
)
}
if (typeof req.logout === 'function') {
// passport logout
const logout = promisify(req.logout.bind(req))

View File

@@ -45,17 +45,22 @@ async function deleteUser(userId, options) {
const user = await User.findById(userId).exec()
logger.info({ userId }, 'deleting user')
await ensureCanDeleteUser(user)
logger.info({ userId }, 'cleaning up user')
await _cleanupUser(user)
logger.info({ userId }, 'firing deleteUser hook')
await Modules.promises.hooks.fire('deleteUser', userId)
// add audit log entry before _cleanUpUser removes any group subscriptions
logger.info({ userId }, 'adding delete-account audit log entry')
await UserAuditLogHandler.promises.addEntry(
userId,
'delete-account',
options.deleterUser ? options.deleterUser._id : userId,
options.ipAddress
options.ipAddress,
{}
)
logger.info({ userId }, 'cleaning up user')
await _cleanupUser(user)
logger.info({ userId }, 'firing deleteUser hook')
await Modules.promises.hooks.fire('deleteUser', userId)
logger.info({ userId }, 'creating deleted user record')
await _createDeletedUser(user, options)
logger.info({ userId }, 'deleting user projects')

View File

@@ -74,6 +74,7 @@ const ANALYTICS_QUEUES = [
'analytics-events',
'analytics-editing-sessions',
'analytics-user-properties',
'analytics-user-exports',
'post-registration-analytics',
]

View File

@@ -101,6 +101,7 @@ export const UserSchema = new Schema(
breadcrumbs: { type: Boolean, default: true },
referencesSearchMode: { type: String, default: 'advanced' }, // 'advanced' or 'simple'
enableNewEditor: { type: Boolean },
darkModePdf: { type: Boolean, default: false },
},
features: {
collaborators: {

View File

@@ -5,7 +5,7 @@ block content
.container
.error-container
.error-details
p.error-status Not found
h1.error-status #{translate("not_found")}
p.error-description #{translate("cant_find_page")}
p.error-actions
a.error-btn(href='/') Home

View File

@@ -14,7 +14,6 @@
"Pricing": "",
"Solutions": "",
"a_custom_size_has_been_used_in_the_latex_code": "",
"a_fatal_compile_error_that_completely_blocks_compilation": "",
"a_file_with_that_name_already_exists_and_will_be_overriden": "",
"a_more_comprehensive_list_of_keyboard_shortcuts": "",
"a_new_reference_was_added": "",
@@ -160,7 +159,6 @@
"as_email": "",
"ask_proj_owner_to_unlink_from_current_github": "",
"ask_proj_owner_to_upgrade_for_full_history": "",
"ask_proj_owner_to_upgrade_for_references_search": "",
"ask_repo_owner_to_reconnect": "",
"ask_repo_owner_to_renew_overleaf_subscription": "",
"at_most_x_libraries_can_be_selected": "",
@@ -281,6 +279,7 @@
"clicking_delete_will_remove_sso_config_and_clear_saml_data": "",
"clone_with_git": "",
"close": "",
"close_dialog": "",
"clsi_maintenance": "",
"clsi_unavailable": "",
"code_check_failed": "",
@@ -300,7 +299,6 @@
"comment_only_upgrade_for_track_changes": "",
"comment_only_upgrade_to_enable_track_changes": "",
"common": "",
"common_causes_of_compile_timeouts_include": "",
"commons_plan_tooltip": "",
"compact": "",
"company_name": "",
@@ -380,6 +378,7 @@
"customize_your_group_subscription": "",
"customizing_figures": "",
"customizing_tables": "",
"dark_mode_pdf_preview": "",
"date_and_owner": "",
"date_and_time": "",
"dealing_with_errors": "",
@@ -551,7 +550,6 @@
"enable_managed_users": "",
"enable_single_sign_on": "",
"enable_sso": "",
"enable_stop_on_first_error_under_recompile_dropdown_menu": "",
"enabled": "",
"enables_real_time_syntax_checking_in_the_editor": "",
"enabling": "",
@@ -903,6 +901,8 @@
"invalid_request": "",
"invalid_tax_id_number": "",
"invalid_upload_request": "",
"invert_pdf_preview_colors": "",
"invert_pdf_preview_colors_when_in_dark_mode": "",
"invite": "",
"invite_expired": "",
"invite_more_collabs": "",
@@ -976,6 +976,7 @@
"let_us_know_how_we_can_help": "",
"let_us_know_what_you_think": "",
"lets_get_those_premium_features": "",
"lets_get_you_set_up": "",
"library": "",
"licenses": "",
"limited_document_history": "",
@@ -1273,6 +1274,7 @@
"pdf_unavailable_for_download": "",
"pdf_viewer": "",
"pdf_viewer_error": "",
"pdf_zoom_level": "",
"pending_additional_licenses": "",
"pending_addon_cancellation": "",
"pending_invite": "",
@@ -1409,7 +1411,6 @@
"ready_to_join_x_in_group_y": "",
"ready_to_set_up": "",
"realtime_track_changes": "",
"reasons_for_compile_timeouts": "",
"reauthorize_github_account": "",
"recaptcha_conditions": "",
"recent_commits_in_github": "",
@@ -1647,6 +1648,7 @@
"show_x_more_projects": "",
"showing_1_result": "",
"showing_1_result_of_total": "",
"showing_pdf_preview_with_inverted_colors": "",
"showing_x_out_of_n_projects": "",
"showing_x_out_of_n_users": "",
"showing_x_results": "",
@@ -2112,6 +2114,7 @@
"value_must_be_at_least_x": "",
"vat": "",
"vat_number": "",
"verification_code": "",
"verify_email_address_before_enabling_managed_users": "",
"verify_your_email_address": "",
"view": "",
@@ -2253,6 +2256,7 @@
"your_current_plan_gives_you": "",
"your_current_plan_supports_up_to_x_licenses": "",
"your_current_project_will_revert_to_the_version_from_time": "",
"your_email_is_confirmed": "",
"your_feedback_matters_answer_two_quick_questions": "",
"your_git_access_info": "",
"your_git_access_info_bullet_1": "",

View File

@@ -36,6 +36,8 @@ export default /** @type {const} */ ([
'info',
'integration_instructions',
'lightbulb',
'lock',
'lock_open',
'more_vert',
'neurology',
'note_add',

View File

@@ -28,6 +28,7 @@ type ProjectSettingsSetterContextValue = {
setPdfViewer: (pdfViewer: UserSettings['pdfViewer']) => void
setMathPreview: (mathPreview: UserSettings['mathPreview']) => void
setBreadcrumbs: (breadcrumbs: UserSettings['breadcrumbs']) => void
setDarkModePdf: (darkModePdf: UserSettings['darkModePdf']) => void
}
type ProjectSettingsContextValue = Partial<ProjectSettings> &
@@ -77,6 +78,8 @@ export const ProjectSettingsProvider: FC<React.PropsWithChildren> = ({
setMathPreview,
breadcrumbs,
setBreadcrumbs,
darkModePdf,
setDarkModePdf,
} = useUserWideSettings()
useProjectWideSettingsSocketListener()
@@ -115,6 +118,8 @@ export const ProjectSettingsProvider: FC<React.PropsWithChildren> = ({
setMathPreview,
breadcrumbs,
setBreadcrumbs,
darkModePdf,
setDarkModePdf,
}),
[
compiler,
@@ -149,6 +154,8 @@ export const ProjectSettingsProvider: FC<React.PropsWithChildren> = ({
setMathPreview,
breadcrumbs,
setBreadcrumbs,
darkModePdf,
setDarkModePdf,
]
)

View File

@@ -21,6 +21,7 @@ export default function useUserWideSettings() {
pdfViewer,
mathPreview,
breadcrumbs,
darkModePdf,
} = userSettings
const setOverallTheme = useSetOverallTheme()
@@ -101,6 +102,13 @@ export default function useUserWideSettings() {
[saveUserSettings]
)
const setDarkModePdf = useCallback(
(darkModePdf: UserSettings['darkModePdf']) => {
saveUserSettings('darkModePdf', darkModePdf)
},
[saveUserSettings]
)
return {
autoComplete,
setAutoComplete,
@@ -126,5 +134,7 @@ export default function useUserWideSettings() {
setMathPreview,
breadcrumbs,
setBreadcrumbs,
darkModePdf,
setDarkModePdf,
}
}

View File

@@ -0,0 +1,18 @@
import ToggleSetting from '../toggle-setting'
import { useTranslation } from 'react-i18next'
import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context'
export default function DarkModePdfSetting() {
const { darkModePdf, setDarkModePdf } = useCompileContext()
const { t } = useTranslation()
return (
<ToggleSetting
id="pdf-dark-mode-setting"
label={t('dark_mode_pdf_preview')}
description={t('invert_pdf_preview_colors_when_in_dark_mode')}
checked={darkModePdf}
onChange={setDarkModePdf}
/>
)
}

View File

@@ -25,6 +25,9 @@ import FontFamilySetting from '../components/settings/appearance-settings/font-f
import { AvailableUnfilledIcon } from '@/shared/components/material-icon'
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
import NewEditorSetting from '../components/settings/editor-settings/new-editor-setting'
import DarkModePdfSetting from '../components/settings/appearance-settings/dark-mode-pdf-setting'
import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context'
import { useFeatureFlag } from '@/shared/context/split-test-context'
const [referenceSearchSettingModule] = importOverleafModules(
'referenceSearchSetting'
@@ -76,9 +79,12 @@ export const SettingsModalProvider: FC<React.PropsWithChildren> = ({
children,
}) => {
const { t } = useTranslation()
const { overallTheme } = useProjectSettingsContext()
// TODO ide-redesign-cleanup: Rename this field and move it directly into this context
const { leftMenuShown, setLeftMenuShown } = useLayoutContext()
const hasDarkModePdf = useFeatureFlag('pdf-dark-mode')
const settingsTabs: SettingsEntry[] = useMemo(
() => [
{
@@ -198,6 +204,11 @@ export const SettingsModalProvider: FC<React.PropsWithChildren> = ({
key: 'editorTheme',
component: <EditorThemeSetting />,
},
{
key: 'pdfDarkMode',
component: <DarkModePdfSetting />,
hidden: overallTheme === 'light-' || !hasDarkModePdf,
},
{
key: 'fontSize',
component: <FontSizeSetting />,
@@ -231,7 +242,7 @@ export const SettingsModalProvider: FC<React.PropsWithChildren> = ({
href: '/user/subscription',
},
],
[t]
[t, overallTheme, hasDarkModePdf]
)
const settingToTabMap = useMemo(() => {

View File

@@ -42,12 +42,7 @@ export const isSplitTestUser = () => {
}
export const canUseNewEditorAsExistingUser = () => {
const isBetaUser = getMeta('ol-user').betaProgram
return (
!canUseNewEditorAsNewUser() &&
(isSplitTestEnabled('editor-redesign') || isBetaUser)
)
return !canUseNewEditorAsNewUser() && isSplitTestEnabled('editor-redesign')
}
export const canUseNewEditorAsNewUser = () => {

View File

@@ -0,0 +1,70 @@
import OLTooltip from '@/shared/components/ol/ol-tooltip'
import { useCallback, useId } from 'react'
import { useTranslation } from 'react-i18next'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
import { useFeatureFlag } from '@/shared/context/split-test-context'
import OLIconButton from '@/shared/components/ol/ol-icon-button'
import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils'
export const PdfHybridThemeButton = () => {
const id = useId()
const { t } = useTranslation()
const splitTestEnabled = useFeatureFlag('pdf-dark-mode')
const usesNewEditor = useIsNewEditorEnabled()
const {
pdfViewer,
darkModePdf,
setDarkModePdf,
activeOverallTheme,
showLogs,
} = useCompileContext()
const onClick = useCallback(() => {
setDarkModePdf(!darkModePdf)
}, [darkModePdf, setDarkModePdf])
if (!usesNewEditor) {
// The old editor does not support dark mode PDF, so don't show the button
return null
}
if (!splitTestEnabled) {
return null
}
if (activeOverallTheme !== 'dark') {
return null
}
if (pdfViewer !== 'pdfjs') {
// We can't affect the theme of the embedded viewer
return null
}
if (showLogs) {
// Don't show the button when logs are shown
return null
}
const tooltipText = darkModePdf
? t('showing_pdf_preview_with_inverted_colors')
: t('invert_pdf_preview_colors')
return (
<OLTooltip
id={id}
description={tooltipText}
overlayProps={{ placement: 'bottom' }}
>
<OLIconButton
icon="invert_colors"
accessibilityLabel={tooltipText}
variant="link"
active={darkModePdf}
className="pdf-toolbar-btn toolbar-item theme-toggle-btn"
onClick={onClick}
style={{ position: 'relative' }}
/>
</OLTooltip>
)
}

View File

@@ -238,6 +238,7 @@ function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) {
window.clearTimeout(storePositionTimer)
}
storePosition.cancel()
setPosition(pdfJsWrapper.currentPosition)
}
}
}, [setPosition, pdfJsWrapper, initialised])

View File

@@ -3,7 +3,8 @@ import PdfPreview from './pdf-preview'
import useWaitForI18n from '../../../shared/hooks/use-wait-for-i18n'
import { ReactContextRoot } from '@/features/ide-react/context/react-context-root'
import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils'
import useThemedPage from '@/shared/hooks/use-themed-page'
import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context'
import { useEffect } from 'react'
function PdfPreviewDetachedRoot() {
const { isReady } = useWaitForI18n()
@@ -20,7 +21,13 @@ function PdfPreviewDetachedRoot() {
}
function PdfPreviewDetachedRootContent() {
useThemedPage() // set the page theme based on user settings
const { activeOverallTheme } = useCompileContext()
useEffect(() => {
// NOTE: We cannot use useThemedPage here because we need to read the
// activeOverallTheme value from the compile context
document.body.dataset.theme =
activeOverallTheme === 'dark' ? 'default' : 'light'
}, [activeOverallTheme])
return (
<EditorRedesignWrapper>

View File

@@ -14,12 +14,28 @@ import importOverleafModules from '../../../../macros/import-overleaf-module.mac
import PdfCodeCheckFailedBanner from '@/features/ide-redesign/components/pdf-preview/pdf-code-check-failed-banner'
import getMeta from '@/utils/meta'
import NewPdfLogsViewer from '@/features/ide-redesign/components/pdf-preview/pdf-logs-viewer'
import { useFeatureFlag } from '@/shared/context/split-test-context'
function PdfPreviewPane() {
const { pdfUrl } = useCompileContext()
const {
pdfUrl,
pdfViewer,
darkModePdf: darkModeSetting,
activeOverallTheme,
} = useCompileContext()
const { compileTimeout } = getMeta('ol-compileSettings')
const usesNewEditor = useIsNewEditorEnabled()
const inDarkModePdfSplitTest = useFeatureFlag('pdf-dark-mode')
const darkModePdf =
inDarkModePdfSplitTest &&
usesNewEditor &&
pdfViewer === 'pdfjs' &&
activeOverallTheme === 'dark' &&
darkModeSetting
const classes = classNames('pdf', 'full-size', {
'pdf-empty': !pdfUrl,
'pdf-dark-mode': darkModePdf,
})
const newEditor = useIsNewEditorEnabled()

View File

@@ -9,6 +9,7 @@ import { useDetachCompileContext as useCompileContext } from '../../../shared/co
import { useCommandProvider } from '@/features/ide-react/hooks/use-command-provider'
import { useTranslation } from 'react-i18next'
import { useLayoutContext } from '@/shared/context/layout-context'
import { PdfHybridThemeButton } from './pdf-hybrid-theme-button'
type PdfViewerControlsToolbarProps = {
requestPresentationMode: () => void
@@ -91,7 +92,7 @@ function PdfViewerControlsToolbar({
}
const InnerControlsComponent =
availableWidth >= 300
availableWidth >= 320
? PdfViewerControlsToolbarFull
: PdfViewerControlsToolbarSmall
@@ -133,6 +134,7 @@ function PdfViewerControlsToolbarFull({
}: InnerControlsProps) {
return (
<>
<PdfHybridThemeButton />
<PdfPageNumberControl
setPage={setPage}
page={page}
@@ -161,6 +163,7 @@ function PdfViewerControlsToolbarSmall({
}: InnerControlsProps) {
return (
<div className="pdfjs-viewer-controls-small">
<PdfHybridThemeButton />
<PdfZoomDropdown
requestPresentationMode={requestPresentationMode}
rawScale={rawScale}

View File

@@ -75,6 +75,7 @@ function PdfZoomDropdown({
id="pdf-zoom-dropdown"
variant="link"
className="pdf-toolbar-btn pdfjs-zoom-dropdown-button small"
aria-label={t('pdf_zoom_level')}
>
{rawScaleToPercentage(rawScale)}
</DropdownToggle>

View File

@@ -3,8 +3,6 @@ import { useDetachCompileContext } from '../../../shared/context/detach-compile-
import StartFreeTrialButton from '../../../shared/components/start-free-trial-button'
import { memo, useCallback, useMemo, useState } from 'react'
import PdfLogEntry from './pdf-log-entry'
import { useStopOnFirstError } from '../../../shared/hooks/use-stop-on-first-error'
import OLButton from '@/shared/components/ol/ol-button'
import * as eventTracking from '../../../infrastructure/event-tracking'
import getMeta from '@/utils/meta'
import { populateEditorRedesignSegmentation } from '@/shared/hooks/use-editor-analytics'
@@ -13,16 +11,8 @@ import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
function TimeoutUpgradePromptNew() {
const {
startCompile,
lastCompileOptions,
setAnimateCompileDropdownArrow,
isProjectOwner,
} = useDetachCompileContext()
const { isProjectOwner } = useDetachCompileContext()
const newEditor = useIsNewEditorEnabled()
const shouldHideCompileTimeoutInfo = isSplitTestEnabled(
'compile-timeout-remove-info'
)
const isCompileTimeoutTargetPlansEnabled = isSplitTestEnabled(
'compile-timeout-target-plans'
@@ -31,16 +21,6 @@ function TimeoutUpgradePromptNew() {
const [showCompileTimeoutPaywall, setShowCompileTimeoutPaywall] =
useState(false)
const { enableStopOnFirstError } = useStopOnFirstError({
eventSource: 'timeout-new',
})
const handleEnableStopOnFirstErrorClick = useCallback(() => {
enableStopOnFirstError()
startCompile({ stopOnFirstError: true })
setAnimateCompileDropdownArrow(true)
}, [enableStopOnFirstError, startCompile, setAnimateCompileDropdownArrow])
const { compileTimeout } = getMeta('ol-compileSettings')
const sharedSegmentation = useMemo(
@@ -64,15 +44,6 @@ function TimeoutUpgradePromptNew() {
onShowPaywallModal={() => setShowCompileTimeoutPaywall(true)}
isCompileTimeoutTargetPlansEnabled={isCompileTimeoutTargetPlansEnabled}
/>
{getMeta('ol-ExposedSettings').enableSubscriptions &&
!shouldHideCompileTimeoutInfo && (
<PreventTimeoutHelpMessage
handleEnableStopOnFirstErrorClick={
handleEnableStopOnFirstErrorClick
}
lastCompileOptions={lastCompileOptions}
/>
)}
<CompileTimeoutPaywallModal
show={showCompileTimeoutPaywall}
onHide={() => setShowCompileTimeoutPaywall(false)}
@@ -165,112 +136,4 @@ const CompileTimeout = memo(function CompileTimeout({
)
})
type PreventTimeoutHelpMessageProps = {
lastCompileOptions: any
handleEnableStopOnFirstErrorClick: () => void
}
const PreventTimeoutHelpMessage = memo(function PreventTimeoutHelpMessage({
lastCompileOptions,
handleEnableStopOnFirstErrorClick,
}: PreventTimeoutHelpMessageProps) {
const { t } = useTranslation()
return (
<PdfLogEntry
autoExpand
headerTitle={t('reasons_for_compile_timeouts')}
formattedContent={
<>
<p>{t('common_causes_of_compile_timeouts_include')}:</p>
<ul>
<li>
<Trans
i18nKey="project_timed_out_optimize_images"
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
<a
href="/learn/how-to/Optimising_very_large_image_files"
rel="noopener noreferrer"
target="_blank"
onClick={() => {
eventTracking.sendMB('paywall-info-click', {
'paywall-type': 'compile-timeout',
content: 'docs',
type: 'optimize',
})
}}
/>,
]}
/>
</li>
<li>
<Trans
i18nKey="a_fatal_compile_error_that_completely_blocks_compilation"
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
<a
href="/learn/how-to/Fixing_and_preventing_compile_timeouts#Step_3:_Assess_your_project_for_time-consuming_tasks_and_fatal_errors"
rel="noopener noreferrer"
target="_blank"
onClick={() => {
eventTracking.sendMB('paywall-info-click', {
'paywall-type': 'compile-timeout',
content: 'docs',
type: 'fatal-error',
})
}}
/>,
]}
/>
{!lastCompileOptions.stopOnFirstError && (
<>
{' '}
<Trans
i18nKey="enable_stop_on_first_error_under_recompile_dropdown_menu"
components={[
// eslint-disable-next-line react/jsx-key
<OLButton
variant="link"
className="btn-inline-link fw-bold"
size="sm"
onClick={handleEnableStopOnFirstErrorClick}
/>,
// eslint-disable-next-line react/jsx-key
<strong />,
]}
/>{' '}
</>
)}
</li>
</ul>
<p>
<Trans
i18nKey="project_timed_out_learn_more"
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
<a
href="/learn/how-to/Fixing_and_preventing_compile_timeouts"
rel="noopener noreferrer"
target="_blank"
onClick={() => {
eventTracking.sendMB('paywall-info-click', {
'paywall-type': 'compile-timeout',
content: 'docs',
type: 'learn-more',
})
}}
/>,
]}
/>
</p>
</>
}
// @ts-ignore
entryAriaLabel={t('reasons_for_compile_timeouts')}
level="raw"
/>
)
})
export default memo(TimeoutUpgradePromptNew)

View File

@@ -0,0 +1,49 @@
import { forwardRef } from 'react'
import { Form, FormControlProps } from 'react-bootstrap'
import classnames from 'classnames'
interface CIAMSixDigitsInputProps extends FormControlProps {
value: string | undefined
}
const separator = '\u2007' // figure space
const CIAMSixDigitsInput = forwardRef<
HTMLInputElement,
CIAMSixDigitsInputProps
>(({ className, onChange, value, ...props }, ref) => {
const group1 = value?.slice(0, 3) || ''
const group2 = value?.slice(3, 6) || ''
const displayValue = group2 ? `${group1}${separator}${group2}` : group1
return (
<div className="ciam-six-digits-container">
<Form.Control
ref={ref}
{...props}
size="lg"
onChange={v => {
const inputValue = v.target.value
const sanitizedValue = inputValue.replaceAll(/\D/g, '').slice(0, 6)
onChange?.({
...v,
target: { ...v.target, value: sanitizedValue },
currentTarget: { ...v.currentTarget, value: sanitizedValue },
})
}}
value={displayValue}
className={classnames(
'form-control-ds ciam-six-digits-input',
className
)}
maxLength={7}
inputMode="numeric"
/>
<span className="ciam-six-digits-dash" aria-hidden>
-
</span>
</div>
)
})
CIAMSixDigitsInput.displayName = 'CIAMSixDigitsInput'
export default CIAMSixDigitsInput

View File

@@ -2,14 +2,25 @@ import { postJSON } from '@/infrastructure/fetch-json'
import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
import Notification from '@/shared/components/notification'
import getMeta from '@/utils/meta'
import { FormEvent, MouseEventHandler, useState } from 'react'
import {
ChangeEventHandler,
ComponentProps,
FormEvent,
MouseEventHandler,
useEffect,
useState,
} from 'react'
import { Trans, useTranslation } from 'react-i18next'
import LoadingSpinner from '@/shared/components/loading-spinner'
import MaterialIcon from '@/shared/components/material-icon'
import { sendMB } from '@/infrastructure/event-tracking'
import OLFormLabel from '@/shared/components/ol/ol-form-label'
import OLButton from '@/shared/components/ol/ol-button'
import { useLocation } from '@/shared/hooks/use-location'
import DSFormLabel from '@/shared/components/ds/ds-form-label'
import DSButton from '@/shared/components/ds/ds-button'
import CIAMSixDigitsInput from '@/features/settings/components/emails/ciam-six-digits-input'
import OLFormText from '@/shared/components/ol/ol-form-text'
import DSFormText from '@/shared/components/ds/ds-form-text'
type Feedback = {
type: 'input' | 'alert'
@@ -19,7 +30,7 @@ type Feedback = {
type ConfirmEmailFormProps = {
confirmationEndpoint: string
flow: string
flow: 'registration' | 'resend' | 'secondary'
resendEndpoint: string
successMessage?: React.ReactNode
successButtonText?: string
@@ -32,6 +43,15 @@ type ConfirmEmailFormProps = {
isCiam?: boolean
}
const OLSixDigitsInput = (props: ComponentProps<'input'>) => (
<input
inputMode="numeric"
maxLength={6}
className="form-control"
{...props}
/>
)
export function ConfirmEmailForm({
confirmationEndpoint,
flow,
@@ -146,7 +166,7 @@ export function ConfirmEmailForm({
})
}
const changeHandler = (e: FormEvent<HTMLInputElement>) => {
const changeHandler: ChangeEventHandler<HTMLInputElement> = e => {
setConfirmationCode(e.currentTarget.value)
setFeedback(null)
}
@@ -161,10 +181,21 @@ export function ConfirmEmailForm({
successMessage={successMessage}
successButtonText={successButtonText}
redirectTo={successRedirectPath}
autoRedirect={isCiam ? 8000 : false}
/>
)
}
const longLabel = isModal
? t('enter_the_code', { email })
: t('enter_the_confirmation_code', { email })
const Button = isCiam ? DSButton : OLButton
const buttonSize = isCiam ? 'lg' : undefined
const SixDigits = isCiam ? CIAMSixDigitsInput : OLSixDigitsInput
const FormText = isCiam ? DSFormText : OLFormText
return (
<form
onSubmit={submitHandler}
@@ -191,53 +222,54 @@ export function ConfirmEmailForm({
outerErrorDisplay={outerErrorDisplay}
/>
<OLFormLabel htmlFor="one-time-code">
{isModal
? t('enter_the_code', { email })
: t('enter_the_confirmation_code', { email })}
</OLFormLabel>
<input
{isCiam && <p>{longLabel}</p>}
{isCiam ? (
<DSFormLabel htmlFor="one-time-code">
{t('verification_code')}
</DSFormLabel>
) : (
<OLFormLabel htmlFor="one-time-code">{longLabel}</OLFormLabel>
)}
<SixDigits
id="one-time-code"
className="form-control"
inputMode="numeric"
required
value={confirmationCode}
onChange={changeHandler}
data-ol-dirty={feedback ? 'true' : undefined}
maxLength={6}
autoComplete="one-time-code"
autoFocus // eslint-disable-line jsx-a11y/no-autofocus
disabled={!!outerErrorDisplay}
/>
<div aria-live="polite">
{feedback?.type === 'input' && (
<div className="small text-danger">
<MaterialIcon className="icon" type="error" />
<div>
<ErrorMessage error={feedback.message} />
</div>
</div>
<FormText type="error" marginless>
<ErrorMessage error={feedback.message} />
</FormText>
)}
</div>
<div className="form-actions">
<OLButton
<Button
size={buttonSize}
disabled={isResending || !!outerErrorDisplay}
type="submit"
isLoading={isConfirming}
loadingLabel={t('confirming')}
>
{t('confirm')}
</OLButton>
<OLButton
</Button>
<Button
variant="secondary"
size={buttonSize}
disabled={isConfirming}
onClick={resendHandler}
isLoading={isResending}
loadingLabel={t('resending_confirmation_code')}
>
{t('resend_confirmation_code')}
</OLButton>
</Button>
{onCancel && (
<OLButton
variant="danger-ghost"
@@ -248,6 +280,25 @@ export function ConfirmEmailForm({
</OLButton>
)}
</div>
{isCiam && flow === 'registration' && (
<div className="mt-4 mb-2 text-center ">
<Trans
i18nKey="use_a_different_email"
components={[
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
<a
href="/register"
onClick={() =>
sendMB('email-verification-click', {
button: 'change-email',
flow,
})
}
/>,
]}
/>
</div>
)}
</div>
</form>
)
@@ -281,10 +332,12 @@ function ConfirmEmailSuccessfullForm({
successMessage,
successButtonText,
redirectTo,
autoRedirect = false,
}: {
successMessage: React.ReactNode
successButtonText: string
redirectTo: string
autoRedirect?: number | false
}) {
const location = useLocation()
const submitHandler = (e: FormEvent<HTMLFormElement>) => {
@@ -292,15 +345,24 @@ function ConfirmEmailSuccessfullForm({
location.assign(redirectTo)
}
useEffect(() => {
if (autoRedirect) {
const timer = setTimeout(() => location.assign(redirectTo), autoRedirect)
return () => clearTimeout(timer)
}
}, [autoRedirect, location, redirectTo])
return (
<form onSubmit={submitHandler}>
<form onSubmit={submitHandler} className="confirm-email-success-form">
<div aria-live="polite">{successMessage}</div>
<div className="form-actions">
<OLButton type="submit" variant="primary">
{successButtonText}
</OLButton>
</div>
{!autoRedirect && (
<div className="form-actions">
<OLButton type="submit" variant="primary">
{successButtonText}
</OLButton>
</div>
)}
</form>
)
}

View File

@@ -140,6 +140,7 @@ const en = {
'ai-context-menu.join': 'Join',
'ai-context-menu.widgets': 'Widgets',
'ai-context-menu.abstract-generator': 'Abstract Generator',
'ai-context-menu.keywords-generator': 'Keywords Generator',
'ai-context-menu.context-options': 'Context Options',
'ai-context-menu.select-text-tooltip':
'Select text to access these options',
@@ -191,6 +192,7 @@ const en = {
'errors.error-hit-limit-freemium.body':
'Youve hit your Writefull quota. Upgrade now for unlimited language suggestions and LaTeX support, and early access to upcoming features like TikZ generation.',
'toolbar.abstract-generator.name': 'Abstract generator',
'toolbar.keywords-generator.name': 'Keywords generator',
'toolbar.title-generator.name': 'Title generator',
'toolbar.create-table.name': 'Create tables',
'toolbar.create-table.tooltip': 'Generate tables instantly',
@@ -526,7 +528,8 @@ const es = {
'ai-context-menu.join': 'Unir',
'ai-context-menu.widgets': 'Widgets',
'ai-context-menu.abstract-generator': 'Generar Abstract',
'ai-context-menu.context-options': 'Opciones de Contexto',
'ai-context-menu.keywords-generator': 'Generar palabras clave',
'ai-context-menu.context-options': 'Opciones de contexto',
'ai-context-menu.select-text-tooltip':
'Seleccione texto para acceder a estas opciones',
'ai-context-menu.paraphrase': 'Parafrasear',
@@ -579,6 +582,7 @@ const es = {
'errors.error-hit-limit-freemium.body':
'Has agotado tu cuota de Writefull. Actualiza ahora para obtener sugerencias de lenguaje ilimitadas y soporte en LaTeX, y acceso anticipado a las próximas funciones de generación como TikZ.',
'toolbar.abstract-generator.name': 'Generar Abstract',
'toolbar.keywords-generator.name': 'Generar Palabras Clave',
'toolbar.title-generator.name': 'Generar Título',
'toolbar.create-table.name': 'Crear tablas',
'toolbar.create-table.tooltip': 'Generar tablas al instante',

View File

@@ -1,5 +1,7 @@
import { forwardRef, ReactNode } from 'react'
import { Button, ButtonProps } from 'react-bootstrap'
import { Button, ButtonProps, Spinner } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import classNames from 'classnames'
type DSButtonProps = Pick<
ButtonProps,
@@ -22,6 +24,8 @@ type DSButtonProps = Pick<
leadingIcon?: ReactNode
trailingIcon?: ReactNode
variant?: 'primary' | 'secondary' | 'tertiary' | 'danger'
isLoading?: boolean
loadingLabel?: string
}
const DSButton = forwardRef<HTMLButtonElement, DSButtonProps>(
@@ -29,6 +33,8 @@ const DSButton = forwardRef<HTMLButtonElement, DSButtonProps>(
{
children,
leadingIcon,
isLoading = false,
loadingLabel,
trailingIcon,
variant = 'primary',
size,
@@ -36,16 +42,40 @@ const DSButton = forwardRef<HTMLButtonElement, DSButtonProps>(
},
ref
) => {
const { t } = useTranslation()
const buttonClassName = classNames('d-inline-grid btn-ds', {
'button-loading': isLoading,
})
const loadingSpinnerClassName =
size === 'lg' ? 'loading-spinner-large' : 'loading-spinner-small'
return (
<Button
className="d-inline-grid btn-ds"
className={buttonClassName}
variant={variant}
size={size}
{...props}
ref={ref}
disabled={isLoading || props.disabled}
data-ol-loading={isLoading}
role={undefined}
>
<span className="button-content">
{isLoading && (
<span className="spinner-container">
<Spinner
size="sm"
animation="border"
aria-hidden="true"
className={loadingSpinnerClassName}
/>
<span className="visually-hidden">
{loadingLabel ?? t('loading')}
</span>
</span>
)}
<span className="button-content" aria-hidden={isLoading}>
{leadingIcon}
{children}
{trailingIcon}

View File

@@ -7,7 +7,10 @@ type TextType = 'success' | 'error'
export type FormTextProps = MergeAndOverride<
BS5FormTextProps,
{ type?: TextType }
{
type?: TextType
marginless?: boolean
}
>
const typeClasses = {
@@ -31,10 +34,18 @@ function FormTextIcon({ type }: { type?: TextType }) {
}
}
function DSFormText({ type, children, className, ...rest }: FormTextProps) {
function DSFormText({
type,
marginless,
children,
className,
...rest
}: FormTextProps) {
return (
<Form.Text
className={classnames('form-text-ds', className, getFormTextClass(type))}
className={classnames('form-text-ds', className, getFormTextClass(type), {
marginless,
})}
{...rest}
>
<span className="form-text-inner-ds">

View File

@@ -9,6 +9,7 @@ export type FormTextProps = MergeAndOverride<
BS5FormTextProps,
{
type?: TextType
marginless?: boolean
}
>
@@ -38,13 +39,14 @@ function FormTextIcon({ type }: { type?: TextType }) {
function FormText({
type = 'default',
marginless,
children,
className,
...rest
}: FormTextProps) {
return (
<Form.Text
className={classnames(className, getFormTextClass(type))}
className={classnames(className, getFormTextClass(type), { marginless })}
{...rest}
>
<span className="form-text-inner">

View File

@@ -8,6 +8,7 @@ import {
} from 'react-bootstrap'
import { ModalBodyProps } from 'react-bootstrap/ModalBody'
import type { Options as FocusTrapOptions } from 'focus-trap'
import { useTranslation } from 'react-i18next'
type OLModalProps = ModalProps & {
size?: 'sm' | 'lg'
@@ -52,8 +53,13 @@ export function OLModalHeader({
closeButton = true,
...props
}: OLModalHeaderProps) {
const { t } = useTranslation()
return (
<Modal.Header closeButton={closeButton} {...props}>
<Modal.Header
closeButton={closeButton}
closeLabel={t('close_dialog')}
{...props}
>
{children}
</Modal.Header>
)

View File

@@ -71,6 +71,9 @@ export const DetachCompileProvider: FC<React.PropsWithChildren> = ({
clearCache: _clearCache,
syncToEntry: _syncToEntry,
recordAction: _recordAction,
darkModePdf: _darkModePdf,
setDarkModePdf: _setDarkModePdf,
activeOverallTheme: _activeOverallTheme,
} = localCompileContext
const [animateCompileDropdownArrow] = useDetachStateWatcher(
@@ -373,6 +376,26 @@ export const DetachCompileProvider: FC<React.PropsWithChildren> = ({
'detacher'
)
const [darkModePdf] = useDetachStateWatcher(
'darkModePdf',
_darkModePdf,
'detacher',
'detached'
)
const setDarkModePdf = useDetachAction(
'setDarkModePdf',
_setDarkModePdf,
'detached',
'detacher'
)
const [activeOverallTheme] = useDetachStateWatcher(
'activeOverallTheme',
_activeOverallTheme,
'detacher',
'detached'
)
useCompileTriggers(startCompile, setChangedAt)
useLogEvents(setShowLogs)
@@ -431,6 +454,9 @@ export const DetachCompileProvider: FC<React.PropsWithChildren> = ({
cleanupCompileResult,
syncToEntry,
recordAction,
darkModePdf,
setDarkModePdf,
activeOverallTheme,
}),
[
animateCompileDropdownArrow,
@@ -484,6 +510,9 @@ export const DetachCompileProvider: FC<React.PropsWithChildren> = ({
cleanupCompileResult,
syncToEntry,
recordAction,
darkModePdf,
setDarkModePdf,
activeOverallTheme,
]
)

View File

@@ -54,6 +54,11 @@ import { captureException } from '@/infrastructure/error-reporter'
import OError from '@overleaf/o-error'
import getMeta from '@/utils/meta'
import type { Annotation } from '../../../../types/annotation'
import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context'
import {
ActiveOverallTheme,
useActiveOverallTheme,
} from '../hooks/use-active-overall-theme'
type PdfFile = Record<string, any>
@@ -121,6 +126,9 @@ export type CompileContext = {
clearCache: () => void
syncToEntry: (value: any, keepCurrentView?: boolean) => void
recordAction: (action: string) => void
darkModePdf: boolean | undefined
setDarkModePdf: (value: boolean) => void
activeOverallTheme: ActiveOverallTheme
}
export const LocalCompileContext = createContext<CompileContext | undefined>(
@@ -165,6 +173,11 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
const { userSettings } = useUserSettingsContext()
const { pdfViewer, syntaxValidation } = userSettings
// The active setting for dark mode PDF
const { darkModePdf, setDarkModePdf } = useProjectSettingsContext()
const activeOverallTheme = useActiveOverallTheme()
// low level details for metrics
const [pdfFile, setPdfFile] = useState<PdfFile | null | undefined>()
@@ -793,6 +806,9 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
cleanupCompileResult,
syncToEntry,
recordAction,
darkModePdf,
setDarkModePdf,
activeOverallTheme,
}),
[
animateCompileDropdownArrow,
@@ -845,6 +861,9 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
toggleLogs,
syncToEntry,
recordAction,
darkModePdf,
setDarkModePdf,
activeOverallTheme,
]
)

View File

@@ -25,6 +25,7 @@ const defaultSettings: UserSettings = {
referencesSearchMode: 'advanced',
enableNewEditor: true,
breadcrumbs: true,
darkModePdf: false,
}
type UserSettingsContextValue = {

View File

@@ -26,7 +26,7 @@ const meta: Meta<typeof DSButton> = {
},
parameters: {
controls: {
include: ['children', 'disabled', 'size', 'variant'],
include: ['children', 'disabled', 'isLoading', 'size', 'variant'],
},
...figmaDesignUrl(
'https://www.figma.com/design/aJQlecvqCS9Ry8b6JA1lQN/DS---Components?node-id=4565-2932&m=dev'

View File

@@ -0,0 +1,44 @@
import { ComponentProps, useEffect, useState } from 'react'
import { Meta } from '@storybook/react'
import { figmaDesignUrl } from '../../../.storybook/utils/figma-design-url'
import DSFormGroup from '@/shared/components/ds/ds-form-group'
import DSFormLabel from '@/shared/components/ds/ds-form-label'
import CIAMSixDigitsInput from '@/features/settings/components/emails/ciam-six-digits-input'
type Args = ComponentProps<typeof CIAMSixDigitsInput>
export const SixDigitsInput = ({ value, ...args }: Args) => {
const [state, setState] = useState(value)
useEffect(() => setState(value), [value])
return (
<div className="ciam-enabled">
<DSFormGroup controlId="form-control-id">
<DSFormLabel>Form input label</DSFormLabel>
<CIAMSixDigitsInput
id="form-control-id"
{...args}
value={state}
onChange={e => setState(e.target.value)}
/>
</DSFormGroup>
</div>
)
}
const meta: Meta<typeof SixDigitsInput> = {
title: 'Shared / DS Components',
component: SixDigitsInput,
argTypes: {
value: { control: 'text' },
},
parameters: {
controls: {
include: ['value'],
},
...figmaDesignUrl(
'https://www.figma.com/design/aJQlecvqCS9Ry8b6JA1lQN/DS---Components?node-id=6318-428&t=pcx9KKzhlzpRmA4S-0'
),
},
}
export default meta

View File

@@ -2,3 +2,4 @@
@import 'ciam-layout';
@import 'ciam-variables';
@import 'ciam-register';
@import 'ciam-six-digits';

View File

@@ -76,6 +76,7 @@
border-radius: var(--ds-border-radius-400);
max-width: 464px;
margin: 0 auto;
min-height: 500px;
@include media-breakpoint-up(sm) {
padding: var(--ds-spacing-1300);

View File

@@ -0,0 +1,26 @@
@import '../ds/mixins';
.ciam-six-digits-container {
position: relative;
min-width: 180px;
.ciam-six-digits-input {
@include ds-heading-md-regular;
letter-spacing: 0.3em;
padding-left: calc(50% - 3.3em) !important;
font-variant-numeric: tabular-nums;
}
.ciam-six-digits-dash {
@include ds-heading-md-regular;
pointer-events: none;
color: var(--ds-color-neutral-400);
text-align: center;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}

View File

@@ -2,16 +2,10 @@
.ciam-enabled,
.website-redesign:not(.application-page) .ciam-enabled .notification {
// Links
// used in services/web/frontend/stylesheets/base/links.scss
// Links, used in services/web/frontend/stylesheets/base/links.scss
--link-color: var(--ds-color-text-primary);
--link-hover-color: var(--ds-color-text-secondary);
--link-visited-color: var(--ds-color-text-secondary);
--link-text-decoration: underline;
--link-hover-text-decoration: none;
// TODO: validate that this is correct
--link-color-dark: fuchsia;
--link-hover-color-dark: fuchsia;
--link-visited-color-dark: fuchsia;
}

View File

@@ -120,6 +120,13 @@
line-height: var(--line-height-02);
margin-top: var(--spacing-04);
}
&.marginless {
&,
.form-text-inner {
margin: 0;
}
}
}
.form-label {

View File

@@ -78,4 +78,37 @@
$disabled-background: var(--ds-color-neutral-100)
);
}
&.button-loading {
.spinner-container {
display: flex;
justify-content: center;
align-items: center;
.loading-spinner-small {
border-width: 0.25em;
height: 1.25rem;
width: 1.25rem;
}
.loading-spinner-large {
border-width: 0.25em;
height: 1.5rem;
width: 1.5rem;
}
.spinner-border {
border-right-color: color-mix(
in srgb,
var(--bs-btn-color) 32%,
var(--bs-btn-bg)
);
}
}
// Hide the text when the spinner is visible
& > [aria-hidden='true'] {
visibility: hidden;
}
}
}

View File

@@ -91,6 +91,13 @@ input.form-control.form-control-ds {
@include ds-body-sm-regular;
}
&.marginless {
&,
.form-text-inner-ds {
margin: 0;
}
}
.ciam-form-text-icon {
font-size: math.div(20em, 14);
}

View File

@@ -48,6 +48,7 @@
@import 'register';
@import 'plans';
@import 'onboarding-confirm-email';
@import 'onboarding-confirm-email-ciam';
@import 'secondary-confirm-email';
@import 'onboarding';
@import 'admin/admin';

View File

@@ -84,6 +84,10 @@
padding-right: 0;
}
.outline-body-no-elements {
margin-right: 0;
}
.outline-item-list {
display: flex;
flex-direction: column;

View File

@@ -208,10 +208,40 @@
outline: none;
}
}
&.theme-toggle-btn {
border: none;
border-radius: var(--border-radius-full);
&.active {
color: var(--white);
background-color: var(--green-70);
&:hover {
background-color: var(--green-60);
}
}
&:focus {
outline: none;
}
}
}
.pdf {
background-color: var(--pdf-bg);
&.pdf-dark-mode {
--pdf-bg: var(--bg-dark-secondary);
.pdfjs-viewer {
--canvas-box-shadow: none;
.page {
filter: invert(95%) hue-rotate(180deg) brightness(90%) contrast(90%);
}
}
}
}
.pdf-viewer,
@@ -234,6 +264,8 @@
.pdfjs-viewer {
@extend .full-size;
--canvas-box-shadow: 0 0 10px rgb(0 0 0 / 50%);
background-color: transparent;
overflow: scroll;
@@ -241,7 +273,7 @@
.canvasWrapper > canvas,
div.pdf-canvas {
background: white;
box-shadow: 0 0 10px rgb(0 0 0 / 50%);
box-shadow: var(--canvas-box-shadow);
}
div.pdf-canvas.pdfng-empty {

View File

@@ -0,0 +1,46 @@
#onboarding-confirm-email .ciam-enabled {
.animated-tick {
height: var(--ds-spacing-1200);
width: var(--ds-spacing-1200);
}
.ciam-email-confirmed-title {
margin-top: var(--ds-spacing-800);
margin-bottom: var(--ds-spacing-600);
}
.confirm-email-success-form {
display: flex;
flex: 1 1 auto;
align-items: center;
flex-direction: column;
justify-content: center;
text-align: center;
}
.confirm-email-form .confirm-email-form-inner {
margin: auto;
max-width: 480px;
.notification {
margin-bottom: var(--spacing-05);
}
.text-danger {
display: flex;
gap: var(--spacing-03);
padding: var(--spacing-02);
}
.form-label {
font-weight: bold;
}
.form-actions {
margin-top: var(--ds-spacing-800);
display: flex;
flex-direction: column;
gap: var(--ds-spacing-400);
}
}
}

View File

@@ -15,7 +15,6 @@
"Terms": "Terms",
"Universities": "Universities",
"a_custom_size_has_been_used_in_the_latex_code": "A custom size has been used in the LaTeX code.",
"a_fatal_compile_error_that_completely_blocks_compilation": "A <0>fatal compile error</0> that completely blocks the compilation.",
"a_file_with_that_name_already_exists_and_will_be_overriden": "A file with that name already exists. That file will be overwritten.",
"a_more_comprehensive_list_of_keyboard_shortcuts": "A more comprehensive list of keyboard shortcuts can be found in <0>this __appName__ project template</0>",
"a_new_reference_was_added": "A new reference was added",
@@ -203,7 +202,6 @@
"ascending": "Ascending",
"ask_proj_owner_to_unlink_from_current_github": "Ask the owner of the project (<0>__projectOwnerEmail__</0>) to unlink the project from the current GitHub repository and create a connection to a different repository.",
"ask_proj_owner_to_upgrade_for_full_history": "Please ask the project owner to upgrade to access this projects full history.",
"ask_proj_owner_to_upgrade_for_references_search": "Please ask the project owner to upgrade to use the References Search feature.",
"ask_repo_owner_to_reconnect": "Ask the GitHub repository owner (<0>__repoOwnerEmail__</0>) to reconnect the project.",
"ask_repo_owner_to_renew_overleaf_subscription": "Ask the GitHub repository owner (<0>__repoOwnerEmail__</0>) to renew their __appName__ subscription and reconnect the project.",
"at_most_x_libraries_can_be_selected": "At most __maxCount__ libraries can be selected",
@@ -358,6 +356,7 @@
"clicking_delete_will_remove_sso_config_and_clear_saml_data": "Clicking <0>Delete</0> will remove your SSO configuration and unlink all users. You can only do this when SSO is disabled in your group settings.",
"clone_with_git": "Clone with Git",
"close": "Close",
"close_dialog": "Close dialog",
"clsi_maintenance": "The compile servers are down for maintenance, and will be back shortly.",
"clsi_unavailable": "Sorry, the compile server for your project was temporarily unavailable. Please try again in a few moments.",
"cn": "Chinese (Simplified)",
@@ -382,7 +381,6 @@
"comment_only_upgrade_for_track_changes": "Comment only. Upgrade for track changes.",
"comment_only_upgrade_to_enable_track_changes": "Comment only. <0>Upgrade</0> to enable track changes.",
"common": "Common",
"common_causes_of_compile_timeouts_include": "Common causes of compile timeouts include",
"commons_plan_tooltip": "Youre on the __plan__ plan because of your affiliation with __institution__. Click to find out how to make the most of your Overleaf premium features.",
"community_articles": "Community articles",
"community_articles_lowercase": "community articles",
@@ -499,6 +497,7 @@
"customizing_tables": "Customizing tables",
"da": "Danish",
"dark_mode": "Dark mode",
"dark_mode_pdf_preview": "Dark mode PDF preview",
"date": "Date",
"date_and_owner": "Date and owner",
"date_and_time": "Date and time",
@@ -703,7 +702,6 @@
"enable_managed_users": "Enable Managed Users",
"enable_single_sign_on": "Enable single sign-on",
"enable_sso": "Enable SSO",
"enable_stop_on_first_error_under_recompile_dropdown_menu": "Enable <0>“Stop on first error”</0> under the <1>Recompile</1> drop-down menu to help you find and fix errors right away.",
"enabled": "Enabled",
"enables_real_time_syntax_checking_in_the_editor": "Enables real-time syntax checking in the editor",
"enabling": "Enabling",
@@ -1148,6 +1146,8 @@
"invalid_tax_id_number": "Invalid tax ID number",
"invalid_upload_request": "The upload failed. If the problem persists, <0>let us know</0>.",
"invalid_zip_file": "Invalid zip file",
"invert_pdf_preview_colors": "Invert PDF preview colors",
"invert_pdf_preview_colors_when_in_dark_mode": "Invert PDF preview colors when in dark mode",
"invite": "Invite",
"invite_expired": "The invite may have expired",
"invite_more_collabs": "Invite more collaborators",
@@ -1256,6 +1256,7 @@
"let_us_know_how_we_can_help": "Let us know how we can help",
"let_us_know_what_you_think": "Let us know what you think",
"lets_get_those_premium_features": "Lets get those premium features up and running for you straightaway. Youll be billed <0>__paymentAmount__</0> using the payment details we have for you.",
"lets_get_you_set_up": "Lets get you set up.",
"libraries": "Libraries",
"library": "Library",
"license": "License",
@@ -1507,6 +1508,7 @@
"normally_x_price_per_month": "Normally __price__ per month",
"normally_x_price_per_year": "Normally __price__ per year",
"not_a_student": "Not a student?",
"not_found": "Not found",
"not_found_error_from_the_supplied_url": "The link to open this content on Overleaf pointed to a file that could not be found. If this keeps happening for links on a particular site, please report this to them.",
"not_managed": "Not managed",
"not_now": "Not now",
@@ -1660,6 +1662,7 @@
"pdf_unavailable_for_download": "PDF unavailable for download",
"pdf_viewer": "PDF Viewer",
"pdf_viewer_error": "There was a problem displaying the PDF for this project.",
"pdf_zoom_level": "PDF zoom level",
"pending": "Pending",
"pending_additional_licenses": "Your subscription is changing to include <0>__pendingAdditionalLicenses__</0> additional license(s) for a total of <1>__pendingTotalLicenses__</1> licenses.",
"pending_addon_cancellation": "Your subscription will change to remove the <strong>__addOnName__</strong> add-on at the end of the current billing period.",
@@ -1825,7 +1828,6 @@
"ready_to_use_templates": "Ready-to-use templates",
"real_time_track_changes": "Real-time track-changes",
"realtime_track_changes": "Real-time track changes",
"reasons_for_compile_timeouts": "Reasons for compile timeouts",
"reauthorize_github_account": "Reauthorize your GitHub Account",
"recaptcha_conditions": "The site is protected by reCAPTCHA and the Google <1>Privacy Policy</1> and <2>Terms of Service</2> apply.",
"recent": "Recent",
@@ -2113,6 +2115,7 @@
"show_x_more_projects": "Show __x__ more projects",
"showing_1_result": "Showing 1 result",
"showing_1_result_of_total": "Showing 1 result of __total__",
"showing_pdf_preview_with_inverted_colors": "Showing PDF preview with inverted colors",
"showing_x_out_of_n_projects": "Showing __x__ out of __n__ projects.",
"showing_x_out_of_n_users": "Showing __x__ out of __n__ users",
"showing_x_results": "Showing __x__ results",
@@ -2653,6 +2656,7 @@
"value_must_be_at_least_x": "Value must be at least __value__",
"vat": "VAT",
"vat_number": "VAT Number",
"verification_code": "Verification code",
"verify_email_address_before_enabling_managed_users": "You need to verify your email address before enabling managed users.",
"verify_your_email_address": "Verify your email address",
"view": "View",
@@ -2810,6 +2814,7 @@
"your_current_plan_gives_you": "By pausing your subscription, youll be able to access your premium features faster when you need them again.",
"your_current_plan_supports_up_to_x_licenses": "Your current plan supports up to __users__ licenses.",
"your_current_project_will_revert_to_the_version_from_time": "Your current project will revert to the version from __timestamp__",
"your_email_is_confirmed": "Your email is confirmed.",
"your_feedback_matters_answer_two_quick_questions": "Your feedback matters! Answer two quick questions.",
"your_git_access_info": "Your Git authentication tokens should be entered whenever youre prompted for a password.",
"your_git_access_info_bullet_1": "You can have up to 10 tokens.",

View File

@@ -81,8 +81,8 @@
"safari > 14"
],
"dependencies": {
"@ai-sdk/mcp": "^1.0.0-beta.15",
"@ai-sdk/openai": "^3.0.0-beta.59",
"@ai-sdk/mcp": "^1.0.0-beta.16",
"@ai-sdk/openai": "^3.0.0-beta.64",
"@aws-sdk/client-ses": "^3.864.0",
"@contentful/rich-text-html-renderer": "^16.0.2",
"@contentful/rich-text-types": "^16.0.2",
@@ -110,7 +110,7 @@
"@stripe/stripe-js": "^7.7.0",
"@xmldom/xmldom": "^0.7.13",
"accepts": "^1.3.7",
"ai": "^6.0.0-beta.99",
"ai": "^6.0.0-beta.111",
"ajv": "^8.12.0",
"archiver": "^5.3.0",
"async": "^3.2.5",
@@ -200,7 +200,7 @@
},
"devDependencies": {
"5to6-codemod": "^1.8.0",
"@ai-sdk/react": "^3.0.0-beta.99",
"@ai-sdk/react": "^3.0.0-beta.111",
"@babel/cli": "^7.27.0",
"@babel/core": "^7.26.10",
"@babel/plugin-proposal-decorators": "^7.27.0",

View File

@@ -0,0 +1,88 @@
// @ts-check
import { db } from '../app/src/infrastructure/mongodb.js'
import { batchedUpdate } from '@overleaf/mongo-utils/batchedUpdate.js'
import { scriptRunner } from './lib/ScriptRunner.mjs'
import CollaboratorsHandler from '../app/src/Features/Collaborators/CollaboratorsHandler.mjs'
const DRY_RUN = !process.argv.includes('--dry-run=false')
const DEBUG = process.argv.includes('--debug=true')
// Deployment procedure:
// Run it locally (not dry run)
// Run it on staging (dry run then real and then real). Maybe leave it a few days but might not get good feedback
// Run on prod on a small number of projects to start with, then on all projects (using BATCH_RANGE_START and BATCH_RANGE_END env vars)
// Are there race conditions here? If someone is editing in parallel. Is it worth doing atomic queries?
/**
* @typedef {Object} Project
* @property {any} _id
* @property {Object} track_changes
*/
/**
* @param {(progress: string) => Promise<void>} trackProgress
* @returns {Promise<void>}
* @async
*/
async function main(trackProgress) {
let projectsProcessed = 0
await batchedUpdate(
db.projects,
{},
/**
* @param {Array<Project>} projects
* @return {Promise<void>}
*/
async function projects(projects) {
for (const project of projects) {
projectsProcessed += 1
if (projectsProcessed % 100000 === 0) {
console.log(projectsProcessed, 'projects processed')
}
await processProject(project)
}
},
{ _id: 1, track_changes: 1 },
undefined,
{ trackProgress }
)
}
async function processProject(project) {
if (DEBUG) {
console.log(
`Processing project ${project._id} with track_changes: ${JSON.stringify(
project.track_changes
)}`
)
}
const newTrackChangesState =
await CollaboratorsHandler.promises.convertTrackChangesToExplicitFormat(
project._id,
project.track_changes
)
if (DEBUG) {
console.log(
`Processed project ${project._id} to have new track_changes: ${JSON.stringify(
newTrackChangesState
)}`
)
}
if (!DRY_RUN) {
await db.projects.updateOne(
{ _id: project._id },
{ $set: { track_changes: newTrackChangesState } }
)
}
}
try {
await scriptRunner(main)
process.exit(0)
} catch (error) {
console.error(error)
process.exit(1)
}

View File

@@ -68,6 +68,70 @@ describe('Sessions', function () {
}
)
})
it('should update audit log on logout', function (done) {
async.series(
[
next => {
redis.clearUserSessions(this.user1, next)
},
// login
next => {
this.user1.login(err => next(err))
},
// logout, should add logout audit log entry (happens in background)
next => {
this.user1.logout(err => next(err))
},
// poll for audit log entry since it's written in the background
next => {
let attempts = 0
const checkAuditLog = () => {
this.user1.getAuditLogWithoutNoise((error, auditLog) => {
if (error) return next(error)
const logoutEntries = auditLog.filter(
entry => entry.operation === 'logout'
)
// If we found the logout entry, we're done
if (logoutEntries.length > 0) {
expect(logoutEntries.length).to.be.greaterThan(0)
const lastLogout = logoutEntries[logoutEntries.length - 1]
expect(lastLogout.operation).to.equal('logout')
expect(lastLogout.ipAddress).to.exist
expect(lastLogout.initiatorId).to.exist
expect(lastLogout.timestamp).to.exist
return next()
}
// Otherwise retry up to 10 times
attempts++
if (attempts >= 10) {
return next(
new Error(
'Logout audit log entry not found after 10 attempts'
)
)
}
setTimeout(checkAuditLog, 25)
})
}
checkAuditLog()
},
],
(err, result) => {
if (err) {
throw err
}
done()
}
)
})
})
describe('two sessions', function () {
@@ -465,11 +529,19 @@ describe('Sessions', function () {
this.user1.getAuditLogWithoutNoise((error, auditLog) => {
expect(error).not.to.exist
expect(auditLog).to.exist
expect(auditLog[0].operation).to.equal('clear-sessions')
expect(auditLog[0].ipAddress).to.exist
expect(auditLog[0].initiatorId).to.exist
expect(auditLog[0].timestamp).to.exist
expect(auditLog[0].info.sessions.length).to.equal(2)
// find the clear-sessions entry
const clearSessionsEntries = auditLog.filter(
entry => entry.operation === 'clear-sessions'
)
expect(clearSessionsEntries.length).to.equal(1)
expect(clearSessionsEntries[0].operation).to.equal(
'clear-sessions'
)
expect(clearSessionsEntries[0].ipAddress).to.exist
expect(clearSessionsEntries[0].initiatorId).to.exist
expect(clearSessionsEntries[0].timestamp).to.exist
expect(clearSessionsEntries[0].info.sessions.length).to.equal(2)
next()
})
},

View File

@@ -58,6 +58,16 @@ class UserHelper {
})
}
/**
* Get auditLog by operation
* @return {object[]}
*/
getAuditLogByOperation(operation) {
return (this.user.auditLog || []).filter(entry => {
return entry.operation === operation
})
}
/**
* Generate default email from unique (per instantiation) user number
* @returns {string} email

View File

@@ -163,7 +163,7 @@ describe('<EditorLeftMenu />', function () {
cy.findByRole('heading', { name: 'Copy project' })
// try closing & re-opening the modal with different methods
cy.findByRole('button', { name: 'Close' }).click()
cy.findByRole('button', { name: 'Close dialog' }).click()
cy.findByRole('button', { name: 'Copy project' }).click()
cy.findByRole('button', { name: 'Cancel' }).click()
cy.findByRole('button', { name: 'Copy project' }).click()

View File

@@ -43,7 +43,7 @@ describe('<OLModal />', function () {
cy.findByRole('button', { name: 'Open modal' }).click()
cy.findByRole('dialog').should('be.visible')
cy.findByLabelText(/enter text/i).should('be.visible')
cy.get('body').type('{esc}')
cy.findByRole('button', { name: 'Close dialog' }).click()
// Modal should hide with single escape (escapeDeactivates: false means FocusTrap doesn't handle it)
cy.findByRole('dialog').should('not.exist')
cy.findByRole('button', { name: 'Open modal' }).should('be.visible')
@@ -70,13 +70,13 @@ describe('<OLModal />', function () {
cy.findByRole('button', { name: 'Open modal' }).click()
cy.findByRole('dialog').should('be.visible')
cy.findByRole('button', { name: 'Close' }).should('be.focused')
cy.findByRole('button', { name: 'Close dialog' }).should('be.focused')
cy.focused().tab()
cy.findByLabelText(/enter text/i).should('be.focused')
cy.focused().tab()
cy.findByRole('button', { name: 'Close the modal' }).should('be.focused')
cy.focused().tab()
cy.findByRole('button', { name: 'Close' }).should('be.focused')
cy.findByRole('button', { name: 'Close dialog' }).should('be.focused')
cy.focused().tab({ shift: true })
cy.findByRole('button', { name: 'Close the modal' }).should('be.focused')
})
@@ -95,7 +95,7 @@ describe('<OLModal />', function () {
cy.mount(<Modal />)
cy.findByRole('button', { name: 'Open modal' }).click()
cy.findByRole('dialog').should('be.visible')
cy.findByRole('button', { name: 'Close' }).click()
cy.findByRole('button', { name: 'Close dialog' }).click()
cy.findByRole('dialog').should('not.exist')
})
@@ -103,7 +103,7 @@ describe('<OLModal />', function () {
cy.mount(<Modal backdrop="static" />)
cy.findByRole('button', { name: 'Open modal' }).click()
cy.findByRole('dialog').should('be.visible')
cy.get('body').type('{esc}')
cy.findByRole('button', { name: 'Close dialog' }).click()
cy.findByRole('dialog').should('not.exist')
})
})

View File

@@ -24,8 +24,8 @@ describe('<SettingsDictionary />', function () {
within(modal).getByRole('heading', { name: 'Edit Dictionary' })
within(modal).getByText('Your custom dictionary is empty.')
const [, closeButton] = within(modal).getAllByRole('button', {
name: 'Close',
const closeButton = within(modal).getByRole('button', {
name: 'Close dialog',
})
fireEvent.click(closeButton)
expect(screen.getByTestId('dictionary-modal')).to.not.be.null

View File

@@ -62,7 +62,7 @@ describe('<NewProjectButton />', function () {
it('close the new project modal when clicking at the top right "x" button', function () {
fireEvent.click(screen.getByRole('menuitem', { name: 'Blank project' }))
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
fireEvent.click(screen.getByRole('button', { name: 'Close dialog' }))
expect(screen.queryByRole('dialog')).to.be.null
})

View File

@@ -26,8 +26,8 @@ describe('<DictionarySetting />', function () {
within(modal).getByRole('heading', { name: 'Edit Dictionary' })
within(modal).getByText('Your custom dictionary is empty.')
const [, closeButton] = within(modal).getAllByRole('button', {
name: 'Close',
const closeButton = within(modal).getByRole('button', {
name: 'Close dialog',
})
fireEvent.click(closeButton)
expect(screen.getByTestId('dictionary-modal')).to.not.be.null

View File

@@ -35,7 +35,7 @@ describe('<LeaveSection />', function () {
)
const cancelButton = screen.getByRole('button', {
name: 'Close',
name: 'Cancel',
})
fireEvent.click(cancelButton)

View File

@@ -141,15 +141,10 @@ describe('<ShareProjectModal/>', function () {
createContextProps()
)
const [headerCloseButton, footerCloseButton] = await screen.findAllByRole(
'button',
{ name: 'Close' }
)
const closeButton = screen.getByRole('button', { name: 'Close dialog' })
await userEvent.click(closeButton)
await userEvent.click(headerCloseButton)
await userEvent.click(footerCloseButton)
expect(handleHide.callCount).to.equal(2)
expect(handleHide.callCount).to.equal(1)
})
it('handles access level "private"', async function () {
@@ -410,7 +405,7 @@ describe('<ShareProjectModal/>', function () {
createContextProps({ publicAccessLevel: 'tokenBased', invites })
)
const [, closeButton] = screen.getAllByRole('button', {
const closeButton = screen.getByRole('button', {
name: 'Close',
})
@@ -446,7 +441,7 @@ describe('<ShareProjectModal/>', function () {
createContextProps({ publicAccessLevel: 'tokenBased', invites })
)
const [, closeButton] = screen.getAllByRole('button', {
const closeButton = screen.getByRole('button', {
name: 'Close',
})
@@ -481,7 +476,7 @@ describe('<ShareProjectModal/>', function () {
createContextProps({ publicAccessLevel: 'tokenBased', members })
)
const [, closeButton] = screen.getAllByRole('button', {
const closeButton = screen.getByRole('button', {
name: 'Close',
})

View File

@@ -14,6 +14,9 @@ describe('InstitutionsFeatures', function () {
}
ctx.PlansLocator = { findLocalPlanInSettings: sinon.stub() }
ctx.institutionPlanCode = 'institution_plan_code'
ctx.InstitutionsGetter = {
promises: { getCurrentAffiliations: sinon.stub().resolves([]) },
}
vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
default: ctx.UserGetter,
@@ -29,10 +32,30 @@ describe('InstitutionsFeatures', function () {
},
}))
vi.doMock(
'../../../../app/src/Features/Institutions/InstitutionsGetter',
() => ({
default: ctx.InstitutionsGetter,
})
)
ctx.InstitutionsFeatures = (await import(modulePath)).default
ctx.emailDataWithLicense = [{ emailHasInstitutionLicence: true }]
ctx.emailDataWithoutLicense = [{ emailHasInstitutionLicence: false }]
ctx.userId = '12345abcde'
ctx.affiliateWithAiBundle = {
institution: { writefullCommonsAccount: true },
}
ctx.affiliateWithoutAiBundle = {
institution: { writefullCommonsAccount: false },
}
ctx.testFeatures = { features: { institution: 'all' } }
ctx.testFeaturesWithAiAddon = {
features: { institution: 'all', aiErrorAssistant: true },
}
ctx.testFeaturesWithNoAddon = {
features: { institution: 'all', aiErrorAssistant: false },
}
})
describe('hasLicence', function () {
@@ -87,7 +110,7 @@ describe('InstitutionsFeatures', function () {
).to.be.rejected
})
it('should return no feaures if user has no plan code', async function (ctx) {
it('should return no features if user has no plan code', async function (ctx) {
ctx.UserGetter.promises.getUserFullEmails.resolves(
ctx.emailDataWithoutLicense
)
@@ -98,6 +121,21 @@ describe('InstitutionsFeatures', function () {
expect(features).to.deep.equal({})
})
it('should return ai features if user has any affiliation with add-on bundle', async function (ctx) {
ctx.InstitutionsGetter.promises.getCurrentAffiliations = sinon
.stub()
.resolves([ctx.affiliateWithoutAiBundle, ctx.affiliateWithAiBundle])
ctx.UserGetter.promises.getUserFullEmails.resolves(
ctx.emailDataWithLicense
)
const features =
await ctx.InstitutionsFeatures.promises.getInstitutionsFeatures(
ctx.userId
)
expect(features).to.deep.equal(ctx.testFeaturesWithAiAddon.features)
})
it('should return feaures if user has affiliations plan code', async function (ctx) {
ctx.UserGetter.promises.getUserFullEmails.resolves(
ctx.emailDataWithLicense

View File

@@ -72,6 +72,9 @@ describe('FeaturesUpdater', function () {
bonus: 'features',
},
},
writefull: {
overleafApiUrl: 'https://www.writefull.com',
},
}
ctx.ReferalFeatures = {
@@ -173,6 +176,10 @@ describe('FeaturesUpdater', function () {
vi.doMock('../../../../app/src/models/Subscription', () => ({}))
vi.doMock('@overleaf/fetch-utils', () => ({
fetchNothing: sinon.stub().resolves(),
}))
ctx.FeaturesUpdater = (await import(MODULE_PATH)).default
})

View File

@@ -15,6 +15,7 @@ vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
describe('TpdsUpdateHandler', function () {
beforeEach(async function (ctx) {
ctx.projectName = 'My recipes'
ctx.userId = new ObjectId()
ctx.projects = {
active1: { _id: new ObjectId(), name: ctx.projectName },
active2: { _id: new ObjectId(), name: ctx.projectName },
@@ -28,8 +29,12 @@ describe('TpdsUpdateHandler', function () {
name: ctx.projectName,
archived: [ctx.userId],
},
trashed: {
_id: new ObjectId(),
name: ctx.projectName,
trashed: [ctx.userId],
},
}
ctx.userId = new ObjectId()
ctx.source = 'dropbox'
ctx.path = `/some/file`
ctx.update = {}
@@ -73,9 +78,10 @@ describe('TpdsUpdateHandler', function () {
ctx.ProjectGetter = {
promises: {
findUsersProjectsByName: sinon.stub(),
findAllUsersProjects: sinon
.stub()
.resolves({ owned: [ctx.projects.active1], readAndWrite: [] }),
findAllUsersProjects: sinon.stub().resolves({
owned: Object.values(ctx.projects),
readAndWrite: [],
}),
},
}
ctx.ProjectHelper = {
@@ -87,6 +93,9 @@ describe('TpdsUpdateHandler', function () {
ctx.ProjectHelper.isArchivedOrTrashed
.withArgs(ctx.projects.archived2, ctx.userId)
.returns(true)
ctx.ProjectHelper.isArchivedOrTrashed
.withArgs(ctx.projects.trashed, ctx.userId)
.returns(true)
ctx.RootDocManager = {
setRootDocAutomaticallyInBackground: sinon.stub(),
}
@@ -172,6 +181,24 @@ describe('TpdsUpdateHandler', function () {
expectProjectNotCreated()
expectUpdateProcessed()
})
describe('with one matching archived project', function () {
beforeEach(function (ctx) {
ctx.projectId = ctx.projects.archived1._id.toString()
})
receiveUpdateById()
expectProjectNotCreated()
expectUpdateNotProcessed()
})
describe('with one matching trashed project', function () {
beforeEach(function (ctx) {
ctx.projectId = ctx.projects.trashed._id.toString()
})
receiveUpdateById()
expectProjectNotCreated()
expectUpdateNotProcessed()
})
})
describe('with no matching project', function () {
@@ -269,6 +296,24 @@ describe('TpdsUpdateHandler', function () {
expectDeleteProcessed()
expectProjectNotDeleted()
})
describe('with one matching archived project', function () {
beforeEach(function (ctx) {
ctx.projectId = ctx.projects.archived1._id.toString()
})
receiveFileDeleteById()
expectDeleteNotProcessed()
expectProjectNotDeleted()
})
describe('with one matching trashed project', function () {
beforeEach(function (ctx) {
ctx.projectId = ctx.projects.trashed._id.toString()
})
receiveFileDeleteById()
expectDeleteNotProcessed()
expectProjectNotDeleted()
})
})
describe('with no matching project', function () {

View File

@@ -120,6 +120,7 @@ describe('UserController', function () {
promises: {
addEntry: sinon.stub().resolves(),
},
addEntryInBackground: sinon.stub(),
}
ctx.RequestContentTypeDetection = {
@@ -597,6 +598,39 @@ describe('UserController', function () {
})
})
it('should set darkModePdf to true', function (ctx) {
return new Promise(resolve => {
ctx.req.body = { darkModePdf: true }
ctx.res.sendStatus = code => {
ctx.user.ace.darkModePdf.should.equal(true)
resolve()
}
ctx.UserController.updateUserSettings(ctx.req, ctx.res)
})
})
it('should set darkModePdf to false', function (ctx) {
return new Promise(resolve => {
ctx.req.body = { darkModePdf: false }
ctx.res.sendStatus = code => {
ctx.user.ace.darkModePdf.should.equal(false)
resolve()
}
ctx.UserController.updateUserSettings(ctx.req, ctx.res)
})
})
it('should keep darkModePdf a boolean', function (ctx) {
return new Promise(resolve => {
ctx.req.body = { darkModePdf: 'foobar' }
ctx.res.sendStatus = code => {
ctx.user.ace.darkModePdf.should.equal(true)
resolve()
}
ctx.UserController.updateUserSettings(ctx.req, ctx.res)
})
})
it('should send an error if the email is 0 len', function (ctx) {
return new Promise(resolve => {
ctx.req.body.email = ''

View File

@@ -69,6 +69,7 @@ describe('UserDeleter', function () {
ctx.SubscriptionLocator = {
promises: {
getUsersSubscription: sinon.stub().resolves(),
getUniqueManagedSubscriptionMemberOf: sinon.stub().resolves(),
},
}
@@ -427,7 +428,8 @@ describe('UserDeleter', function () {
ctx.userId,
'delete-account',
ctx.userId,
ctx.ipAddress
ctx.ipAddress,
{}
)
})
})
@@ -508,7 +510,8 @@ describe('UserDeleter', function () {
ctx.userId,
'delete-account',
ctx.deleterId,
ctx.ipAddress
ctx.ipAddress,
{}
)
})
@@ -524,6 +527,81 @@ describe('UserDeleter', function () {
})
})
})
describe('when the user is part of a managed subscription', function () {
beforeEach(function (ctx) {
ctx.managedSubscriptionId = new ObjectId()
ctx.SubscriptionLocator.promises.getUniqueManagedSubscriptionMemberOf.resolves(
{
_id: ctx.managedSubscriptionId,
}
)
ctx.DeletedUserMock.expects('updateOne')
.withArgs(
{ 'deleterData.deletedUserId': ctx.userId },
ctx.deletedUser,
{ upsert: true }
)
.chain('exec')
.resolves()
ctx.UserMock.expects('deleteOne')
.withArgs({ _id: ctx.userId })
.chain('exec')
.resolves()
})
it('should include managedSubscriptionId in audit log', async function (ctx) {
await ctx.UserDeleter.promises.deleteUser(ctx.userId, {
ipAddress: ctx.ipAddress,
})
expect(
ctx.UserAuditLogHandler.promises.addEntry
).to.have.been.calledWith(
ctx.userId,
'delete-account',
ctx.userId,
ctx.ipAddress,
{}
)
})
})
describe('when checking managed subscription fails', function () {
beforeEach(function (ctx) {
ctx.SubscriptionLocator.promises.getUniqueManagedSubscriptionMemberOf.rejects(
new Error('subscription lookup failed')
)
ctx.DeletedUserMock.expects('updateOne')
.withArgs(
{ 'deleterData.deletedUserId': ctx.userId },
ctx.deletedUser,
{ upsert: true }
)
.chain('exec')
.resolves()
ctx.UserMock.expects('deleteOne')
.withArgs({ _id: ctx.userId })
.chain('exec')
.resolves()
})
it('should continue with deletion and not include managedSubscriptionId', async function (ctx) {
await ctx.UserDeleter.promises.deleteUser(ctx.userId, {
ipAddress: ctx.ipAddress,
})
expect(
ctx.UserAuditLogHandler.promises.addEntry
).to.have.been.calledWith(
ctx.userId,
'delete-account',
ctx.userId,
ctx.ipAddress,
{}
)
})
})
})
describe('when the user cannot be deleted because they are a subscription admin', function () {

View File

@@ -18,4 +18,5 @@ export type UserSettings = {
referencesSearchMode: 'advanced' | 'simple'
enableNewEditor: boolean
breadcrumbs: boolean
darkModePdf: boolean
}

View File

@@ -93,4 +93,9 @@ module.exports = merge(base, {
preset: 'minimal',
colors: true,
},
ignoreWarnings: [
// ignore some "Can't resolve '*'" warnings for dynamically-imported optional peer dependencies
/@ai-sdk\/provider-utils\/dist/,
],
})