mirror of
https://github.com/overleaf/overleaf.git
synced 2025-12-05 01:10:29 +00:00
Compare commits
24 Commits
d4992914c2
...
b63ce40914
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b63ce40914 | ||
|
|
2aa2862a77 | ||
|
|
4186321ed7 | ||
|
|
763bede00a | ||
|
|
0d9fa6c0a6 | ||
|
|
e590543bc5 | ||
|
|
2c00a7a3a4 | ||
|
|
4f5638348e | ||
|
|
1b2a52ad7f | ||
|
|
b514ebcc8e | ||
|
|
241a4b6b03 | ||
|
|
ec9d2d83d8 | ||
|
|
e7c92b15cc | ||
|
|
ba61b0dfd4 | ||
|
|
b4bfff1b67 | ||
|
|
7dce5f0e25 | ||
|
|
af148bafb3 | ||
|
|
547ae18f73 | ||
|
|
377e431146 | ||
|
|
6e2f999a11 | ||
|
|
dd8451d51d | ||
|
|
44bd4ab790 | ||
|
|
5eca30db82 | ||
|
|
93e6a230c7 |
11
libraries/eslint-plugin/index.js
Normal file
11
libraries/eslint-plugin/index.js
Normal 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'),
|
||||
},
|
||||
}
|
||||
21
libraries/eslint-plugin/no-generated-editor-themes.js
Normal file
21
libraries/eslint-plugin/no-generated-editor-themes.js
Normal 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.`,
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
43
libraries/eslint-plugin/no-unnecessary-trans.js
Normal file
43
libraries/eslint-plugin/no-unnecessary-trans.js
Normal 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}')}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
17
libraries/eslint-plugin/package.json
Normal file
17
libraries/eslint-plugin/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
83
libraries/eslint-plugin/prefer-kebab-url-ignore.js
Normal file
83
libraries/eslint-plugin/prefer-kebab-url-ignore.js
Normal 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 }
|
||||
91
libraries/eslint-plugin/prefer-kebab-url.js
Normal file
91
libraries/eslint-plugin/prefer-kebab-url.js
Normal 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),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
49
libraries/eslint-plugin/require-loading-label.js
Normal file
49
libraries/eslint-plugin/require-loading-label.js
Normal 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',
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
28
libraries/eslint-plugin/require-script-runner.js
Normal file
28
libraries/eslint-plugin/require-script-runner.js
Normal 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.',
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
121
libraries/eslint-plugin/require-vi-doMock-valid-path.js
Normal file
121
libraries/eslint-plugin/require-vi-doMock-valid-path.js
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
};
|
||||
161
libraries/eslint-plugin/rules.test.js
Normal file
161
libraries/eslint-plugin/rules.test.js
Normal 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.'}
|
||||
]
|
||||
}]
|
||||
})
|
||||
60
libraries/eslint-plugin/should-unescape-trans.js
Normal file
60
libraries/eslint-plugin/should-unescape-trans.js
Normal 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',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
6
libraries/eslint-plugin/tsconfig.json
Normal file
6
libraries/eslint-plugin/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "../../tsconfig.backend.json",
|
||||
"include": [
|
||||
"**/*.js",
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
165
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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.')
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
9
services/web/Jenkinsfile
vendored
9
services/web/Jenkinsfile
vendored
@@ -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') {
|
||||
|
||||
@@ -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 || {}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -74,6 +74,7 @@ const ANALYTICS_QUEUES = [
|
||||
'analytics-events',
|
||||
'analytics-editing-sessions',
|
||||
'analytics-user-properties',
|
||||
'analytics-user-exports',
|
||||
'post-registration-analytics',
|
||||
]
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
Binary file not shown.
@@ -36,6 +36,8 @@ export default /** @type {const} */ ([
|
||||
'info',
|
||||
'integration_instructions',
|
||||
'lightbulb',
|
||||
'lock',
|
||||
'lock_open',
|
||||
'more_vert',
|
||||
'neurology',
|
||||
'note_add',
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -238,6 +238,7 @@ function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) {
|
||||
window.clearTimeout(storePositionTimer)
|
||||
}
|
||||
storePosition.cancel()
|
||||
setPosition(pdfJsWrapper.currentPosition)
|
||||
}
|
||||
}
|
||||
}, [setPosition, pdfJsWrapper, initialised])
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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':
|
||||
'You’ve 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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ const defaultSettings: UserSettings = {
|
||||
referencesSearchMode: 'advanced',
|
||||
enableNewEditor: true,
|
||||
breadcrumbs: true,
|
||||
darkModePdf: false,
|
||||
}
|
||||
|
||||
type UserSettingsContextValue = {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
@@ -2,3 +2,4 @@
|
||||
@import 'ciam-layout';
|
||||
@import 'ciam-variables';
|
||||
@import 'ciam-register';
|
||||
@import 'ciam-six-digits';
|
||||
|
||||
@@ -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);
|
||||
|
||||
26
services/web/frontend/stylesheets/ciam/ciam-six-digits.scss
Normal file
26
services/web/frontend/stylesheets/ciam/ciam-six-digits.scss
Normal 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%);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -120,6 +120,13 @@
|
||||
line-height: var(--line-height-02);
|
||||
margin-top: var(--spacing-04);
|
||||
}
|
||||
|
||||
&.marginless {
|
||||
&,
|
||||
.form-text-inner {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-label {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -84,6 +84,10 @@
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.outline-body-no-elements {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.outline-item-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 project’s 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": "You’re 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": "Let’s get those premium features up and running for you straightaway. You’ll be billed <0>__paymentAmount__</0> using the payment details we have for you.",
|
||||
"lets_get_you_set_up": "Let’s 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, you’ll 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 you’re prompted for a password.",
|
||||
"your_git_access_info_bullet_1": "You can have up to 10 tokens.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('<LeaveSection />', function () {
|
||||
)
|
||||
|
||||
const cancelButton = screen.getByRole('button', {
|
||||
name: 'Close',
|
||||
name: 'Cancel',
|
||||
})
|
||||
|
||||
fireEvent.click(cancelButton)
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -18,4 +18,5 @@ export type UserSettings = {
|
||||
referencesSearchMode: 'advanced' | 'simple'
|
||||
enableNewEditor: boolean
|
||||
breadcrumbs: boolean
|
||||
darkModePdf: boolean
|
||||
}
|
||||
|
||||
@@ -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/,
|
||||
],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user