diff --git a/README.md b/README.md index 4895254926..524a831262 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,11 @@ [Overleaf](https://www.overleaf.com) is an open-source online real-time collaborative LaTeX editor. We run a hosted version at [www.overleaf.com](https://www.overleaf.com), but you can also run your own local version, and contribute to the development of Overleaf. +> [!CAUTION] +> Overleaf Community Edition is intended for use in environments where **all** users are trusted. Community Edition is **not** appropriate for scenarios where isolation of users is required due to Sandbox Compiles not being available. When not using Sandboxed Compiles, users have full read and write access to the `sharelatex` container resources (filesystem, network, environment variables) when running LaTeX compiles. + +For more information on Sandbox Compiles check out our [documentation](https://docs.overleaf.com/on-premises/configuration/overleaf-toolkit/server-pro-only-configuration/sandboxed-compiles). + ## Enterprise If you want help installing and maintaining Overleaf in your lab or workplace, we offer an officially supported version called [Overleaf Server Pro](https://www.overleaf.com/for/enterprises). It also includes more features for security (SSO with LDAP or SAML), administration and collaboration (e.g. tracked changes). [Find out more!](https://www.overleaf.com/for/enterprises) diff --git a/develop/README.md b/develop/README.md index 568259c4e3..8d45383c23 100644 --- a/develop/README.md +++ b/develop/README.md @@ -42,7 +42,7 @@ To do this, use the included `bin/dev` script: bin/dev ``` -This will start all services using `nodemon`, which will automatically monitor the code and restart the services as necessary. +This will start all services using `node --watch`, which will automatically monitor the code and restart the services as necessary. To improve performance, you can start only a subset of the services in development mode by providing a space-separated list to the `bin/dev` script: diff --git a/develop/dev.env b/develop/dev.env index aae91497db..6ebbbb1ffd 100644 --- a/develop/dev.env +++ b/develop/dev.env @@ -6,14 +6,17 @@ DOCUMENT_UPDATER_HOST=document-updater FILESTORE_HOST=filestore GRACEFUL_SHUTDOWN_DELAY_SECONDS=0 HISTORY_V1_HOST=history-v1 +HISTORY_REDIS_HOST=redis LISTEN_ADDRESS=0.0.0.0 MONGO_HOST=mongo MONGO_URL=mongodb://mongo/sharelatex?directConnection=true NOTIFICATIONS_HOST=notifications PROJECT_HISTORY_HOST=project-history +QUEUES_REDIS_HOST=redis REALTIME_HOST=real-time REDIS_HOST=redis SESSION_SECRET=foo +V1_HISTORY_HOST=history-v1 WEBPACK_HOST=webpack WEB_API_PASSWORD=overleaf WEB_API_USER=overleaf diff --git a/develop/docker-compose.dev.yml b/develop/docker-compose.dev.yml index 4432a24162..3d2fca7e0b 100644 --- a/develop/docker-compose.dev.yml +++ b/develop/docker-compose.dev.yml @@ -113,7 +113,7 @@ services: - ../services/real-time/config:/overleaf/services/real-time/config web: - command: ["node", "--watch", "app.js", "--watch-locales"] + command: ["node", "--watch", "app.mjs", "--watch-locales"] environment: - NODE_OPTIONS=--inspect=0.0.0.0:9229 ports: diff --git a/doc/logo.png b/doc/logo.png index 106926b095..f154ac0cca 100644 Binary files a/doc/logo.png and b/doc/logo.png differ diff --git a/libraries/access-token-encryptor/.nvmrc b/libraries/access-token-encryptor/.nvmrc index 8320a6d299..fc37597bcc 100644 --- a/libraries/access-token-encryptor/.nvmrc +++ b/libraries/access-token-encryptor/.nvmrc @@ -1 +1 @@ -22.15.1 +22.17.0 diff --git a/libraries/access-token-encryptor/buildscript.txt b/libraries/access-token-encryptor/buildscript.txt index 8ce12073ea..8f08720e7e 100644 --- a/libraries/access-token-encryptor/buildscript.txt +++ b/libraries/access-token-encryptor/buildscript.txt @@ -5,6 +5,6 @@ access-token-encryptor --env-pass-through= --esmock-loader=False --is-library=True ---node-version=22.15.1 +--node-version=22.17.0 --public-repo=False --script-version=4.7.0 diff --git a/libraries/fetch-utils/.nvmrc b/libraries/fetch-utils/.nvmrc index 8320a6d299..fc37597bcc 100644 --- a/libraries/fetch-utils/.nvmrc +++ b/libraries/fetch-utils/.nvmrc @@ -1 +1 @@ -22.15.1 +22.17.0 diff --git a/libraries/fetch-utils/buildscript.txt b/libraries/fetch-utils/buildscript.txt index 35e8eed85b..7008a533a6 100644 --- a/libraries/fetch-utils/buildscript.txt +++ b/libraries/fetch-utils/buildscript.txt @@ -5,6 +5,6 @@ fetch-utils --env-pass-through= --esmock-loader=False --is-library=True ---node-version=22.15.1 +--node-version=22.17.0 --public-repo=False --script-version=4.7.0 diff --git a/libraries/logger/.nvmrc b/libraries/logger/.nvmrc index 8320a6d299..fc37597bcc 100644 --- a/libraries/logger/.nvmrc +++ b/libraries/logger/.nvmrc @@ -1 +1 @@ -22.15.1 +22.17.0 diff --git a/libraries/logger/buildscript.txt b/libraries/logger/buildscript.txt index a3d1cc0646..e2520c4800 100644 --- a/libraries/logger/buildscript.txt +++ b/libraries/logger/buildscript.txt @@ -5,6 +5,6 @@ logger --env-pass-through= --esmock-loader=False --is-library=True ---node-version=22.15.1 +--node-version=22.17.0 --public-repo=False --script-version=4.7.0 diff --git a/libraries/metrics/.nvmrc b/libraries/metrics/.nvmrc index 8320a6d299..fc37597bcc 100644 --- a/libraries/metrics/.nvmrc +++ b/libraries/metrics/.nvmrc @@ -1 +1 @@ -22.15.1 +22.17.0 diff --git a/libraries/metrics/buildscript.txt b/libraries/metrics/buildscript.txt index 58ff195d95..d1e272c356 100644 --- a/libraries/metrics/buildscript.txt +++ b/libraries/metrics/buildscript.txt @@ -5,6 +5,6 @@ metrics --env-pass-through= --esmock-loader=False --is-library=True ---node-version=22.15.1 +--node-version=22.17.0 --public-repo=False --script-version=4.7.0 diff --git a/libraries/mongo-utils/.nvmrc b/libraries/mongo-utils/.nvmrc index 8320a6d299..fc37597bcc 100644 --- a/libraries/mongo-utils/.nvmrc +++ b/libraries/mongo-utils/.nvmrc @@ -1 +1 @@ -22.15.1 +22.17.0 diff --git a/libraries/mongo-utils/buildscript.txt b/libraries/mongo-utils/buildscript.txt index 35ca540bfb..ff07040970 100644 --- a/libraries/mongo-utils/buildscript.txt +++ b/libraries/mongo-utils/buildscript.txt @@ -5,6 +5,6 @@ mongo-utils --env-pass-through= --esmock-loader=False --is-library=True ---node-version=22.15.1 +--node-version=22.17.0 --public-repo=False --script-version=4.7.0 diff --git a/libraries/o-error/.nvmrc b/libraries/o-error/.nvmrc index 8320a6d299..fc37597bcc 100644 --- a/libraries/o-error/.nvmrc +++ b/libraries/o-error/.nvmrc @@ -1 +1 @@ -22.15.1 +22.17.0 diff --git a/libraries/o-error/buildscript.txt b/libraries/o-error/buildscript.txt index c61679157e..6a9334411a 100644 --- a/libraries/o-error/buildscript.txt +++ b/libraries/o-error/buildscript.txt @@ -5,6 +5,6 @@ o-error --env-pass-through= --esmock-loader=False --is-library=True ---node-version=22.15.1 +--node-version=22.17.0 --public-repo=False --script-version=4.7.0 diff --git a/libraries/object-persistor/.nvmrc b/libraries/object-persistor/.nvmrc index 8320a6d299..fc37597bcc 100644 --- a/libraries/object-persistor/.nvmrc +++ b/libraries/object-persistor/.nvmrc @@ -1 +1 @@ -22.15.1 +22.17.0 diff --git a/libraries/object-persistor/buildscript.txt b/libraries/object-persistor/buildscript.txt index d5113ce910..897e789700 100644 --- a/libraries/object-persistor/buildscript.txt +++ b/libraries/object-persistor/buildscript.txt @@ -5,6 +5,6 @@ object-persistor --env-pass-through= --esmock-loader=False --is-library=True ---node-version=22.15.1 +--node-version=22.17.0 --public-repo=False --script-version=4.7.0 diff --git a/libraries/object-persistor/src/PerProjectEncryptedS3Persistor.js b/libraries/object-persistor/src/PerProjectEncryptedS3Persistor.js index 7bd4bb93e5..a0230128fe 100644 --- a/libraries/object-persistor/src/PerProjectEncryptedS3Persistor.js +++ b/libraries/object-persistor/src/PerProjectEncryptedS3Persistor.js @@ -33,6 +33,10 @@ const AES256_KEY_LENGTH = 32 * @property {() => Promise>} getRootKeyEncryptionKeys */ +/** + * @typedef {import('./types').ListDirectoryResult} ListDirectoryResult + */ + /** * Helper function to make TS happy when accessing error properties * AWSError is not an actual class, so we cannot use instanceof. @@ -391,9 +395,9 @@ class PerProjectEncryptedS3Persistor extends S3Persistor { * A general "cache" for project keys is another alternative. For now, use a helper class. */ class CachedPerProjectEncryptedS3Persistor { - /** @type SSECOptions */ + /** @type SSECOptions */ #projectKeyOptions - /** @type PerProjectEncryptedS3Persistor */ + /** @type PerProjectEncryptedS3Persistor */ #parent /** @@ -424,6 +428,16 @@ class CachedPerProjectEncryptedS3Persistor { return await this.#parent.getObjectSize(bucketName, path) } + /** + * + * @param {string} bucketName + * @param {string} path + * @return {Promise} + */ + async listDirectory(bucketName, path) { + return await this.#parent.listDirectory(bucketName, path) + } + /** * @param {string} bucketName * @param {string} path diff --git a/libraries/object-persistor/src/S3Persistor.js b/libraries/object-persistor/src/S3Persistor.js index 2835a271ff..1849838ba4 100644 --- a/libraries/object-persistor/src/S3Persistor.js +++ b/libraries/object-persistor/src/S3Persistor.js @@ -20,6 +20,18 @@ const { URL } = require('node:url') const { WriteError, ReadError, NotFoundError } = require('./Errors') const zlib = require('node:zlib') +/** + * @typedef {import('aws-sdk/clients/s3').ListObjectsV2Output} ListObjectsV2Output + */ + +/** + * @typedef {import('aws-sdk/clients/s3').Object} S3Object + */ + +/** + * @typedef {import('./types').ListDirectoryResult} ListDirectoryResult + */ + /** * Wrapper with private fields to avoid revealing them on console, JSON.stringify or similar. */ @@ -266,26 +278,12 @@ class S3Persistor extends AbstractPersistor { * @return {Promise} */ async deleteDirectory(bucketName, key, continuationToken) { - let response - const options = { Bucket: bucketName, Prefix: key } - if (continuationToken) { - options.ContinuationToken = continuationToken - } - - try { - response = await this._getClientForBucket(bucketName) - .listObjectsV2(options) - .promise() - } catch (err) { - throw PersistorHelper.wrapError( - err, - 'failed to list objects in S3', - { bucketName, key }, - ReadError - ) - } - - const objects = response.Contents?.map(item => ({ Key: item.Key || '' })) + const { contents, response } = await this.listDirectory( + bucketName, + key, + continuationToken + ) + const objects = contents.map(item => ({ Key: item.Key || '' })) if (objects?.length) { try { await this._getClientForBucket(bucketName) @@ -316,6 +314,36 @@ class S3Persistor extends AbstractPersistor { } } + /** + * + * @param {string} bucketName + * @param {string} key + * @param {string} [continuationToken] + * @return {Promise} + */ + async listDirectory(bucketName, key, continuationToken) { + let response + const options = { Bucket: bucketName, Prefix: key } + if (continuationToken) { + options.ContinuationToken = continuationToken + } + + try { + response = await this._getClientForBucket(bucketName) + .listObjectsV2(options) + .promise() + } catch (err) { + throw PersistorHelper.wrapError( + err, + 'failed to list objects in S3', + { bucketName, key }, + ReadError + ) + } + + return { contents: response.Contents ?? [], response } + } + /** * @param {string} bucketName * @param {string} key diff --git a/libraries/object-persistor/src/types.d.ts b/libraries/object-persistor/src/types.d.ts new file mode 100644 index 0000000000..5640685a5f --- /dev/null +++ b/libraries/object-persistor/src/types.d.ts @@ -0,0 +1,6 @@ +import type { ListObjectsV2Output, Object } from 'aws-sdk/clients/s3' + +export type ListDirectoryResult = { + contents: Array + response: ListObjectsV2Output +} diff --git a/libraries/overleaf-editor-core/.nvmrc b/libraries/overleaf-editor-core/.nvmrc index 8320a6d299..fc37597bcc 100644 --- a/libraries/overleaf-editor-core/.nvmrc +++ b/libraries/overleaf-editor-core/.nvmrc @@ -1 +1 @@ -22.15.1 +22.17.0 diff --git a/libraries/overleaf-editor-core/buildscript.txt b/libraries/overleaf-editor-core/buildscript.txt index 25a221232a..c0391ca30a 100644 --- a/libraries/overleaf-editor-core/buildscript.txt +++ b/libraries/overleaf-editor-core/buildscript.txt @@ -5,6 +5,6 @@ overleaf-editor-core --env-pass-through= --esmock-loader=False --is-library=True ---node-version=22.15.1 +--node-version=22.17.0 --public-repo=False --script-version=4.7.0 diff --git a/libraries/overleaf-editor-core/lib/change.js b/libraries/overleaf-editor-core/lib/change.js index ff4b973a3c..cfc3447251 100644 --- a/libraries/overleaf-editor-core/lib/change.js +++ b/libraries/overleaf-editor-core/lib/change.js @@ -13,7 +13,7 @@ const V2DocVersions = require('./v2_doc_versions') /** * @import Author from "./author" - * @import { BlobStore, RawChange } from "./types" + * @import { BlobStore, RawChange, ReadonlyBlobStore } from "./types" */ /** @@ -219,7 +219,7 @@ class Change { * If this Change contains any File objects, load them. * * @param {string} kind see {File#load} - * @param {BlobStore} blobStore + * @param {ReadonlyBlobStore} blobStore * @return {Promise} */ async loadFiles(kind, blobStore) { diff --git a/libraries/overleaf-editor-core/lib/history.js b/libraries/overleaf-editor-core/lib/history.js index d9d1253f34..6a97a6f8a9 100644 --- a/libraries/overleaf-editor-core/lib/history.js +++ b/libraries/overleaf-editor-core/lib/history.js @@ -7,7 +7,7 @@ const Change = require('./change') const Snapshot = require('./snapshot') /** - * @import { BlobStore } from "./types" + * @import { BlobStore, ReadonlyBlobStore } from "./types" */ class History { @@ -85,7 +85,7 @@ class History { * If this History contains any File objects, load them. * * @param {string} kind see {File#load} - * @param {BlobStore} blobStore + * @param {ReadonlyBlobStore} blobStore * @return {Promise} */ async loadFiles(kind, blobStore) { diff --git a/libraries/overleaf-editor-core/lib/operation/index.js b/libraries/overleaf-editor-core/lib/operation/index.js index ebc6f73907..ae7f2bcf01 100644 --- a/libraries/overleaf-editor-core/lib/operation/index.js +++ b/libraries/overleaf-editor-core/lib/operation/index.js @@ -13,7 +13,7 @@ let EditFileOperation = null let SetFileMetadataOperation = null /** - * @import { BlobStore } from "../types" + * @import { ReadonlyBlobStore } from "../types" * @import Snapshot from "../snapshot" */ @@ -80,7 +80,7 @@ class Operation { * If this operation references any files, load the files. * * @param {string} kind see {File#load} - * @param {BlobStore} blobStore + * @param {ReadOnlyBlobStore} blobStore * @return {Promise} */ async loadFiles(kind, blobStore) {} diff --git a/libraries/promise-utils/.nvmrc b/libraries/promise-utils/.nvmrc index 8320a6d299..fc37597bcc 100644 --- a/libraries/promise-utils/.nvmrc +++ b/libraries/promise-utils/.nvmrc @@ -1 +1 @@ -22.15.1 +22.17.0 diff --git a/libraries/promise-utils/buildscript.txt b/libraries/promise-utils/buildscript.txt index 32c9fc8793..a9f0fb0c76 100644 --- a/libraries/promise-utils/buildscript.txt +++ b/libraries/promise-utils/buildscript.txt @@ -5,6 +5,6 @@ promise-utils --env-pass-through= --esmock-loader=False --is-library=True ---node-version=22.15.1 +--node-version=22.17.0 --public-repo=False --script-version=4.7.0 diff --git a/libraries/ranges-tracker/.nvmrc b/libraries/ranges-tracker/.nvmrc index 8320a6d299..fc37597bcc 100644 --- a/libraries/ranges-tracker/.nvmrc +++ b/libraries/ranges-tracker/.nvmrc @@ -1 +1 @@ -22.15.1 +22.17.0 diff --git a/libraries/ranges-tracker/buildscript.txt b/libraries/ranges-tracker/buildscript.txt index be28fc1d80..013d56f3ba 100644 --- a/libraries/ranges-tracker/buildscript.txt +++ b/libraries/ranges-tracker/buildscript.txt @@ -5,6 +5,6 @@ ranges-tracker --env-pass-through= --esmock-loader=False --is-library=True ---node-version=22.15.1 +--node-version=22.17.0 --public-repo=False --script-version=4.7.0 diff --git a/libraries/redis-wrapper/.nvmrc b/libraries/redis-wrapper/.nvmrc index 8320a6d299..fc37597bcc 100644 --- a/libraries/redis-wrapper/.nvmrc +++ b/libraries/redis-wrapper/.nvmrc @@ -1 +1 @@ -22.15.1 +22.17.0 diff --git a/libraries/redis-wrapper/buildscript.txt b/libraries/redis-wrapper/buildscript.txt index 395bc706ac..54d43d5092 100644 --- a/libraries/redis-wrapper/buildscript.txt +++ b/libraries/redis-wrapper/buildscript.txt @@ -5,6 +5,6 @@ redis-wrapper --env-pass-through= --esmock-loader=False --is-library=True ---node-version=22.15.1 +--node-version=22.17.0 --public-repo=False --script-version=4.7.0 diff --git a/libraries/settings/.nvmrc b/libraries/settings/.nvmrc index 8320a6d299..fc37597bcc 100644 --- a/libraries/settings/.nvmrc +++ b/libraries/settings/.nvmrc @@ -1 +1 @@ -22.15.1 +22.17.0 diff --git a/libraries/settings/buildscript.txt b/libraries/settings/buildscript.txt index d4daff96d5..9c7632f23f 100644 --- a/libraries/settings/buildscript.txt +++ b/libraries/settings/buildscript.txt @@ -5,6 +5,6 @@ settings --env-pass-through= --esmock-loader=False --is-library=True ---node-version=22.15.1 +--node-version=22.17.0 --public-repo=False --script-version=4.7.0 diff --git a/libraries/stream-utils/.nvmrc b/libraries/stream-utils/.nvmrc index 8320a6d299..fc37597bcc 100644 --- a/libraries/stream-utils/.nvmrc +++ b/libraries/stream-utils/.nvmrc @@ -1 +1 @@ -22.15.1 +22.17.0 diff --git a/libraries/stream-utils/buildscript.txt b/libraries/stream-utils/buildscript.txt index 1da6bdade9..5af61cc683 100644 --- a/libraries/stream-utils/buildscript.txt +++ b/libraries/stream-utils/buildscript.txt @@ -5,6 +5,6 @@ stream-utils --env-pass-through= --esmock-loader=False --is-library=True ---node-version=22.15.1 +--node-version=22.17.0 --public-repo=False --script-version=4.7.0 diff --git a/libraries/stream-utils/index.js b/libraries/stream-utils/index.js index e4c7d60c94..7719d409a4 100644 --- a/libraries/stream-utils/index.js +++ b/libraries/stream-utils/index.js @@ -145,6 +145,24 @@ class LoggerStream extends Transform { } } +class MeteredStream extends Transform { + #Metrics + #metric + #labels + + constructor(Metrics, metric, labels) { + super() + this.#Metrics = Metrics + this.#metric = metric + this.#labels = labels + } + + _transform(chunk, encoding, callback) { + this.#Metrics.count(this.#metric, chunk.byteLength, 1, this.#labels) + callback(null, chunk) + } +} + // Export our classes module.exports = { @@ -153,6 +171,7 @@ module.exports = { LoggerStream, LimitedStream, TimeoutStream, + MeteredStream, SizeExceededError, AbortError, } diff --git a/package-lock.json b/package-lock.json index 2a3bb7696d..2b3a5868a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,8 +41,8 @@ "@types/chai": "^4.3.0", "@types/chai-as-promised": "^7.1.8", "@types/mocha": "^10.0.6", - "@typescript-eslint/eslint-plugin": "^8.0.0", - "@typescript-eslint/parser": "^8.0.0", + "@typescript-eslint/eslint-plugin": "^8.30.1", + "@typescript-eslint/parser": "^8.30.1", "eslint": "^8.15.0", "eslint-config-prettier": "^8.5.0", "eslint-config-standard": "^17.0.0", @@ -51,12 +51,15 @@ "eslint-plugin-cypress": "^2.15.1", "eslint-plugin-import": "^2.26.0", "eslint-plugin-mocha": "^10.1.0", - "eslint-plugin-node": "^11.1.0", + "eslint-plugin-n": "^15.7.0", "eslint-plugin-prettier": "^4.0.0", "eslint-plugin-promise": "^6.0.0", "eslint-plugin-unicorn": "^56.0.0", - "prettier": "3.3.3", - "typescript": "^5.5.4" + "prettier": "3.6.2", + "typescript": "^5.8.3" + }, + "engines": { + "npm": "11.4.2" } }, "jobs/mirror-documentation": { @@ -108,7 +111,7 @@ "lodash": "^4.17.21" }, "devDependencies": { - "@typescript-eslint/parser": "^6.7.5" + "@typescript-eslint/parser": "^8.30.1" } }, "libraries/eslint-plugin/node_modules/@typescript-eslint/parser": { @@ -516,14 +519,6 @@ "typescript": "^5.0.4" } }, - "node_modules/@aashutoshrathi/word-wrap": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", - "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/@adobe/css-tools": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.2.tgz", @@ -1672,21 +1667,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/highlight": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz", - "integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==", - "dev": true, - "dependencies": { - "@babel/helper-validator-identifier": "^7.24.7", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/parser": { "version": "7.27.0", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz", @@ -4806,23 +4786,28 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "license": "MIT", "dependencies": { - "eslint-visitor-keys": "^3.3.0" + "eslint-visitor-keys": "^3.4.3" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, + "funding": { + "url": "https://opencollective.com/eslint" + }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.1.tgz", - "integrity": "sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "license": "Apache-2.0", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -4875,9 +4860,10 @@ } }, "node_modules/@eslint/js": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", - "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "license": "MIT", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } @@ -6097,11 +6083,13 @@ "integrity": "sha512-aKmlCO57XFZ26wso4rJsW4oTUnrgTFw2jh3io7CAtO9w4UltBNwRXvXIVzzyfkaaLRo3nluP/19msA8vDUUuKw==" }, "node_modules/@humanwhocodes/config-array": { - "version": "0.11.14", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "license": "Apache-2.0", "dependencies": { - "@humanwhocodes/object-schema": "^2.0.2", + "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", "minimatch": "^3.0.5" }, @@ -6126,12 +6114,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@humanwhocodes/object-schema": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", - "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", - "dev": true - }, "node_modules/@icons/material": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/@icons/material/-/material-0.2.4.tgz", @@ -8911,6 +8893,33 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@prettier/plugin-pug": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@prettier/plugin-pug/-/plugin-pug-3.4.0.tgz", + "integrity": "sha512-Jzd5rE/ellJz3vqfxyVewPsCHXw1dmIzJ3AXhAnqVBKQOj2u73ZS2oUacji8CbQSsYyCy7GXFjXWDlDTMG1x2g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/Shinigami92" + }, + { + "type": "paypal", + "url": "https://www.paypal.com/donate/?hosted_button_id=L7GY729FBKTZY" + } + ], + "license": "MIT", + "dependencies": { + "pug-lexer": "^5.0.1" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=9.0.0" + }, + "peerDependencies": { + "prettier": "^3.0.0" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -9386,6 +9395,13 @@ "win32" ] }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, "node_modules/@sentry-internal/tracing": { "version": "7.46.0", "resolved": "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.46.0.tgz", @@ -12525,16 +12541,6 @@ "csstype": "^3.0.2" } }, - "node_modules/@types/react-bootstrap": { - "version": "0.32.37", - "resolved": "https://registry.npmjs.org/@types/react-bootstrap/-/react-bootstrap-0.32.37.tgz", - "integrity": "sha512-CVHj++uxsj1pRnM3RQ/NAXcWj+JwJZ3MqQ28sS1OQUD1sI2gRlbeAjRT+ak2nuwL+CY+gtnIsMaIDq0RNfN0PA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/react": "*" - } - }, "node_modules/@types/react-color": { "version": "3.0.13", "resolved": "https://registry.npmjs.org/@types/react-color/-/react-color-3.0.13.tgz", @@ -12809,15 +12815,6 @@ "@types/webidl-conversions": "*" } }, - "node_modules/@types/workerpool": { - "version": "6.4.7", - "resolved": "https://registry.npmjs.org/@types/workerpool/-/workerpool-6.4.7.tgz", - "integrity": "sha512-DI2U4obcMzFViyNjLw0xXspim++qkAJ4BWRdYPVMMFtOpTvMr6PAk3UTZEoSqnZnvgUkJ3ck97Ybk+iIfuJHMg==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -12887,20 +12884,21 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.0.1.tgz", - "integrity": "sha512-5g3Y7GDFsJAnY4Yhvk8sZtFfV6YNF2caLzjrRPUBzewjPCaj0yokePB4LJSobyCzGMzjZZYFbwuzbfDHlimXbQ==", + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.1.tgz", + "integrity": "sha512-9XNTlo7P7RJxbVeICaIIIEipqxLKguyh+3UbXuT2XQuFp6d8VOeDEGuz5IiX0dgZo8CiI6aOFLg4e8cF71SFVg==", "dev": true, + "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.0.1", - "@typescript-eslint/type-utils": "8.0.1", - "@typescript-eslint/utils": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1", + "@typescript-eslint/scope-manager": "8.35.1", + "@typescript-eslint/type-utils": "8.35.1", + "@typescript-eslint/utils": "8.35.1", + "@typescript-eslint/visitor-keys": "8.35.1", "graphemer": "^1.4.0", - "ignore": "^5.3.1", + "ignore": "^7.0.0", "natural-compare": "^1.4.0", - "ts-api-utils": "^1.3.0" + "ts-api-utils": "^2.1.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -12910,23 +12908,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", - "eslint": "^8.57.0 || ^9.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "@typescript-eslint/parser": "^8.35.1", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.0.1.tgz", - "integrity": "sha512-NpixInP5dm7uukMiRyiHjRKkom5RIFA4dfiHvalanD2cF0CLUuQqxfg8PtEUo9yqJI2bBhF+pcSafqnG3UBnRQ==", + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.35.1.tgz", + "integrity": "sha512-s/Bpd4i7ht2934nG+UoSPlYXd08KYz3bmjLEb7Ye1UVob0d1ENiT3lY8bsCmik4RqfSbPw9xJJHbugpPpP5JUg==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1" + "@typescript-eslint/types": "8.35.1", + "@typescript-eslint/visitor-keys": "8.35.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -12937,10 +12932,11 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.0.1.tgz", - "integrity": "sha512-PpqTVT3yCA/bIgJ12czBuE3iBlM3g4inRSC5J0QOdQFAn07TYrYEQBBKgXH1lQpglup+Zy6c1fxuwTk4MTNKIw==", + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.1.tgz", + "integrity": "sha512-q/O04vVnKHfrrhNAscndAn1tuQhIkwqnaW+eu5waD5IPts2eX1dgJxgqcPx5BX109/qAz7IG6VrEPTOYKCNfRQ==", "dev": true, + "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -12950,13 +12946,14 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.0.1.tgz", - "integrity": "sha512-W5E+o0UfUcK5EgchLZsyVWqARmsM7v54/qEq6PY3YI5arkgmCzHiuk0zKSJJbm71V0xdRna4BGomkCTXz2/LkQ==", + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.35.1.tgz", + "integrity": "sha512-VRwixir4zBWCSTP/ljEo091lbpypz57PoeAQ9imjG+vbeof9LplljsL1mos4ccG6H9IjfrVGM359RozUnuFhpw==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.0.1", - "eslint-visitor-keys": "^3.4.3" + "@typescript-eslint/types": "8.35.1", + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -12967,27 +12964,52 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, + "license": "Apache-2.0", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" } }, - "node_modules/@typescript-eslint/parser": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.0.1.tgz", - "integrity": "sha512-5IgYJ9EO/12pOUwiBKFkpU7rS3IU21mtXzB81TNwq2xEybcmAZrE9qwDtsb5uQd9aVO9o0fdabFyAmKveXyujg==", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.35.1.tgz", + "integrity": "sha512-3MyiDfrfLeK06bi/g9DqJxP5pV74LNv4rFTyvGDmT3x2p1yp1lOd+qYZfiRPIOf/oON+WRZR5wxxuF85qOar+w==", + "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.0.1", - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/typescript-estree": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1", + "@typescript-eslint/scope-manager": "8.35.1", + "@typescript-eslint/types": "8.35.1", + "@typescript-eslint/typescript-estree": "8.35.1", + "@typescript-eslint/visitor-keys": "8.35.1", "debug": "^4.3.4" }, "engines": { @@ -12998,22 +13020,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.0.1.tgz", - "integrity": "sha512-NpixInP5dm7uukMiRyiHjRKkom5RIFA4dfiHvalanD2cF0CLUuQqxfg8PtEUo9yqJI2bBhF+pcSafqnG3UBnRQ==", + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.35.1.tgz", + "integrity": "sha512-s/Bpd4i7ht2934nG+UoSPlYXd08KYz3bmjLEb7Ye1UVob0d1ENiT3lY8bsCmik4RqfSbPw9xJJHbugpPpP5JUg==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1" + "@typescript-eslint/types": "8.35.1", + "@typescript-eslint/visitor-keys": "8.35.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -13024,10 +13043,11 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.0.1.tgz", - "integrity": "sha512-PpqTVT3yCA/bIgJ12czBuE3iBlM3g4inRSC5J0QOdQFAn07TYrYEQBBKgXH1lQpglup+Zy6c1fxuwTk4MTNKIw==", + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.1.tgz", + "integrity": "sha512-q/O04vVnKHfrrhNAscndAn1tuQhIkwqnaW+eu5waD5IPts2eX1dgJxgqcPx5BX109/qAz7IG6VrEPTOYKCNfRQ==", "dev": true, + "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -13037,19 +13057,22 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.0.1.tgz", - "integrity": "sha512-8V9hriRvZQXPWU3bbiUV4Epo7EvgM6RTs+sUmxp5G//dBGy402S7Fx0W0QkB2fb4obCF8SInoUzvTYtc3bkb5w==", + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.35.1.tgz", + "integrity": "sha512-Vvpuvj4tBxIka7cPs6Y1uvM7gJgdF5Uu9F+mBJBPY4MhvjrjWGK4H0lVgLJd/8PWZ23FTqsaJaLEkBCFUk8Y9g==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1", + "@typescript-eslint/project-service": "8.35.1", + "@typescript-eslint/tsconfig-utils": "8.35.1", + "@typescript-eslint/types": "8.35.1", + "@typescript-eslint/visitor-keys": "8.35.1", "debug": "^4.3.4", - "globby": "^11.1.0", + "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", - "ts-api-utils": "^1.3.0" + "ts-api-utils": "^2.1.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -13058,20 +13081,19 @@ "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.0.1.tgz", - "integrity": "sha512-W5E+o0UfUcK5EgchLZsyVWqARmsM7v54/qEq6PY3YI5arkgmCzHiuk0zKSJJbm71V0xdRna4BGomkCTXz2/LkQ==", + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.35.1.tgz", + "integrity": "sha512-VRwixir4zBWCSTP/ljEo091lbpypz57PoeAQ9imjG+vbeof9LplljsL1mos4ccG6H9IjfrVGM359RozUnuFhpw==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.0.1", - "eslint-visitor-keys": "^3.4.3" + "@typescript-eslint/types": "8.35.1", + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -13081,31 +13103,24 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/parser/node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/@typescript-eslint/parser/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } }, "node_modules/@typescript-eslint/parser/node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "dev": true, + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -13117,42 +13132,24 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, + "license": "Apache-2.0", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" } }, - "node_modules/@typescript-eslint/parser/node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@typescript-eslint/parser/node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -13163,17 +13160,12 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@typescript-eslint/parser/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, "node_modules/@typescript-eslint/parser/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -13181,13 +13173,71 @@ "node": ">=10" } }, - "node_modules/@typescript-eslint/parser/node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "node_modules/@typescript-eslint/parser/node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", "dev": true, + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.35.1.tgz", + "integrity": "sha512-VYxn/5LOpVxADAuP3NrnxxHYfzVtQzLKeldIhDhzC8UHaiQvYlXvKuVho1qLduFbJjjy5U5bkGwa3rUGUb1Q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.35.1", + "@typescript-eslint/types": "^8.35.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/project-service/node_modules/@typescript-eslint/types": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.1.tgz", + "integrity": "sha512-q/O04vVnKHfrrhNAscndAn1tuQhIkwqnaW+eu5waD5IPts2eX1dgJxgqcPx5BX109/qAz7IG6VrEPTOYKCNfRQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/project-service/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, "node_modules/@typescript-eslint/scope-manager": { @@ -13207,16 +13257,34 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.0.1.tgz", - "integrity": "sha512-+/UT25MWvXeDX9YaHv1IS6KI1fiuTto43WprE7pgSMswHbn1Jm9GEM4Txp+X74ifOWV8emu2AWcbLhpJAvD5Ng==", + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.35.1.tgz", + "integrity": "sha512-K5/U9VmT9dTHoNowWZpz+/TObS3xqC5h0xAIjXPw+MNcKV9qg6eSatEnmeAwkjHijhACH0/N7bkhKvbt1+DXWQ==", "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.35.1.tgz", + "integrity": "sha512-HOrUBlfVRz5W2LIKpXzZoy6VTZzMu2n8q9C2V/cFngIC5U1nStJgv0tMV4sZPzdf4wQm9/ToWUFPMN9Vq9VJQQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.0.1", - "@typescript-eslint/utils": "8.0.1", + "@typescript-eslint/typescript-estree": "8.35.1", + "@typescript-eslint/utils": "8.35.1", "debug": "^4.3.4", - "ts-api-utils": "^1.3.0" + "ts-api-utils": "^2.1.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -13225,17 +13293,17 @@ "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.0.1.tgz", - "integrity": "sha512-PpqTVT3yCA/bIgJ12czBuE3iBlM3g4inRSC5J0QOdQFAn07TYrYEQBBKgXH1lQpglup+Zy6c1fxuwTk4MTNKIw==", + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.1.tgz", + "integrity": "sha512-q/O04vVnKHfrrhNAscndAn1tuQhIkwqnaW+eu5waD5IPts2eX1dgJxgqcPx5BX109/qAz7IG6VrEPTOYKCNfRQ==", "dev": true, + "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -13245,19 +13313,22 @@ } }, "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.0.1.tgz", - "integrity": "sha512-8V9hriRvZQXPWU3bbiUV4Epo7EvgM6RTs+sUmxp5G//dBGy402S7Fx0W0QkB2fb4obCF8SInoUzvTYtc3bkb5w==", + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.35.1.tgz", + "integrity": "sha512-Vvpuvj4tBxIka7cPs6Y1uvM7gJgdF5Uu9F+mBJBPY4MhvjrjWGK4H0lVgLJd/8PWZ23FTqsaJaLEkBCFUk8Y9g==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1", + "@typescript-eslint/project-service": "8.35.1", + "@typescript-eslint/tsconfig-utils": "8.35.1", + "@typescript-eslint/types": "8.35.1", + "@typescript-eslint/visitor-keys": "8.35.1", "debug": "^4.3.4", - "globby": "^11.1.0", + "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", - "ts-api-utils": "^1.3.0" + "ts-api-utils": "^2.1.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -13266,20 +13337,19 @@ "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.0.1.tgz", - "integrity": "sha512-W5E+o0UfUcK5EgchLZsyVWqARmsM7v54/qEq6PY3YI5arkgmCzHiuk0zKSJJbm71V0xdRna4BGomkCTXz2/LkQ==", + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.35.1.tgz", + "integrity": "sha512-VRwixir4zBWCSTP/ljEo091lbpypz57PoeAQ9imjG+vbeof9LplljsL1mos4ccG6H9IjfrVGM359RozUnuFhpw==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.0.1", - "eslint-visitor-keys": "^3.4.3" + "@typescript-eslint/types": "8.35.1", + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -13289,31 +13359,24 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/type-utils/node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/@typescript-eslint/type-utils/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } }, "node_modules/@typescript-eslint/type-utils/node_modules/debug": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", - "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "dev": true, + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -13325,42 +13388,24 @@ } }, "node_modules/@typescript-eslint/type-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, + "license": "Apache-2.0", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" } }, - "node_modules/@typescript-eslint/type-utils/node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@typescript-eslint/type-utils/node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -13371,17 +13416,12 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@typescript-eslint/type-utils/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, "node_modules/@typescript-eslint/type-utils/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -13389,13 +13429,17 @@ "node": ">=10" } }, - "node_modules/@typescript-eslint/type-utils/node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "node_modules/@typescript-eslint/type-utils/node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", "dev": true, + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" } }, "node_modules/@typescript-eslint/types": { @@ -13537,15 +13581,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.0.1.tgz", - "integrity": "sha512-CBFR0G0sCt0+fzfnKaciu9IBsKvEKYwN9UZ+eeogK1fYHg4Qxk1yf/wLQkLXlq8wbU2dFlgAesxt8Gi76E8RTA==", + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.35.1.tgz", + "integrity": "sha512-lhnwatFmOFcazAsUm3ZnZFpXSxiwoa1Lj50HphnDe1Et01NF4+hrdXONSUHIcbVu2eFb1bAf+5yjXkGVkXBKAQ==", "dev": true, + "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.0.1", - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/typescript-estree": "8.0.1" + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.35.1", + "@typescript-eslint/types": "8.35.1", + "@typescript-eslint/typescript-estree": "8.35.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -13555,17 +13600,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0" + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.0.1.tgz", - "integrity": "sha512-NpixInP5dm7uukMiRyiHjRKkom5RIFA4dfiHvalanD2cF0CLUuQqxfg8PtEUo9yqJI2bBhF+pcSafqnG3UBnRQ==", + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.35.1.tgz", + "integrity": "sha512-s/Bpd4i7ht2934nG+UoSPlYXd08KYz3bmjLEb7Ye1UVob0d1ENiT3lY8bsCmik4RqfSbPw9xJJHbugpPpP5JUg==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1" + "@typescript-eslint/types": "8.35.1", + "@typescript-eslint/visitor-keys": "8.35.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -13576,10 +13623,11 @@ } }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.0.1.tgz", - "integrity": "sha512-PpqTVT3yCA/bIgJ12czBuE3iBlM3g4inRSC5J0QOdQFAn07TYrYEQBBKgXH1lQpglup+Zy6c1fxuwTk4MTNKIw==", + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.1.tgz", + "integrity": "sha512-q/O04vVnKHfrrhNAscndAn1tuQhIkwqnaW+eu5waD5IPts2eX1dgJxgqcPx5BX109/qAz7IG6VrEPTOYKCNfRQ==", "dev": true, + "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -13589,19 +13637,22 @@ } }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.0.1.tgz", - "integrity": "sha512-8V9hriRvZQXPWU3bbiUV4Epo7EvgM6RTs+sUmxp5G//dBGy402S7Fx0W0QkB2fb4obCF8SInoUzvTYtc3bkb5w==", + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.35.1.tgz", + "integrity": "sha512-Vvpuvj4tBxIka7cPs6Y1uvM7gJgdF5Uu9F+mBJBPY4MhvjrjWGK4H0lVgLJd/8PWZ23FTqsaJaLEkBCFUk8Y9g==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.0.1", - "@typescript-eslint/visitor-keys": "8.0.1", + "@typescript-eslint/project-service": "8.35.1", + "@typescript-eslint/tsconfig-utils": "8.35.1", + "@typescript-eslint/types": "8.35.1", + "@typescript-eslint/visitor-keys": "8.35.1", "debug": "^4.3.4", - "globby": "^11.1.0", + "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", - "ts-api-utils": "^1.3.0" + "ts-api-utils": "^2.1.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -13610,20 +13661,19 @@ "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.0.1.tgz", - "integrity": "sha512-W5E+o0UfUcK5EgchLZsyVWqARmsM7v54/qEq6PY3YI5arkgmCzHiuk0zKSJJbm71V0xdRna4BGomkCTXz2/LkQ==", + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.35.1.tgz", + "integrity": "sha512-VRwixir4zBWCSTP/ljEo091lbpypz57PoeAQ9imjG+vbeof9LplljsL1mos4ccG6H9IjfrVGM359RozUnuFhpw==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.0.1", - "eslint-visitor-keys": "^3.4.3" + "@typescript-eslint/types": "8.35.1", + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -13633,31 +13683,24 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/utils/node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/@typescript-eslint/utils/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } }, "node_modules/@typescript-eslint/utils/node_modules/debug": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", - "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "dev": true, + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -13669,42 +13712,24 @@ } }, "node_modules/@typescript-eslint/utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, + "license": "Apache-2.0", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" } }, - "node_modules/@typescript-eslint/utils/node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@typescript-eslint/utils/node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -13715,17 +13740,12 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@typescript-eslint/utils/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, "node_modules/@typescript-eslint/utils/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -13733,13 +13753,17 @@ "node": ">=10" } }, - "node_modules/@typescript-eslint/utils/node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "node_modules/@typescript-eslint/utils/node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", "dev": true, + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" } }, "node_modules/@typescript-eslint/visitor-keys": { @@ -13797,7 +13821,6 @@ "integrity": "sha512-iQGAUO4ziQRpfv7kix6tO6JOWqjI0K4vt8AynvHWzDPZxYSba3zd6RojGNPsYWSR7Xv+dRXYx+GU8oTiK1FRUA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@transloadit/prettier-bytes": "^0.3.4", "@uppy/store-default": "^3.2.2", @@ -15054,33 +15077,6 @@ "node": ">=8" } }, - "node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/ansi-styles/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/ansi-styles/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", - "dev": true - }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -15278,16 +15274,20 @@ "dev": true }, "node_modules/array-includes": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.6.tgz", - "integrity": "sha512-sgTbLvL6cNnw24FnbaDyjmvddQ2ML8arZsgaJhoABMoplz/4QRhtrYS+alr1BUM1Bwp6dhx8vVCBSLG+StwOFw==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "get-intrinsic": "^1.1.3", - "is-string": "^1.0.7" + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -15324,15 +15324,60 @@ "node": ">=0.10.0" } }, - "node_modules/array.prototype.flat": { + "node_modules/array.prototype.findlast": { "version": "1.2.5", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.2.5.tgz", - "integrity": "sha512-KaYU+S+ndVqyUnignHftkwc58o3uVU1jzczILJ1tN2YaIZpFIKBiP/x/j97E5MVPsaCloPbqWLB/8qCTVvT2qg==", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.0" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -15342,15 +15387,16 @@ } }, "node_modules/array.prototype.flatmap": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.1.tgz", - "integrity": "sha512-8UGn9O1FDVvMNB0UlLv4voxRMze7+FpHyF5mSMRjWHUMlpoDViniy05870VlxhfgTnLbpuwTzvD76MTtWxB/mQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "es-shim-unscopables": "^1.0.0" + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -15360,16 +15406,20 @@ } }, "node_modules/array.prototype.tosorted": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.1.tgz", - "integrity": "sha512-pZYPXPRl2PqWcsUs6LOMn+1f1532nEoPTYowBtqLwAW+W8vSVhkIGnmOX1t/UQjD6YGI0vcD2B1U7ZFGQH9jnQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "es-shim-unscopables": "^1.0.0", - "get-intrinsic": "^1.1.3" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" } }, "node_modules/arraybuffer.prototype.slice": { @@ -15459,10 +15509,11 @@ } }, "node_modules/ast-types-flow": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz", - "integrity": "sha1-9wtzXGvKGlycItmCw+Oef+ujva0=", - "dev": true + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" }, "node_modules/astral-regex": { "version": "2.0.0", @@ -15646,10 +15697,11 @@ "integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==" }, "node_modules/axe-core": { - "version": "4.7.1", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.7.1.tgz", - "integrity": "sha512-sCXXUhA+cljomZ3ZAwb8i1p3oOlkABzPy08ZDAoGcYuvtBPlQ1Ytde129ArXyHWDhfeewq7rlx9F+cUx2SSlkg==", + "version": "4.10.3", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz", + "integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==", "dev": true, + "license": "MPL-2.0", "engines": { "node": ">=4" } @@ -15671,12 +15723,13 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, "node_modules/axobject-query": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz", - "integrity": "sha512-goKlv8DZrK9hUh975fnHzhNIO4jUnFCfv/dszV5VwUGDFjI6vQ2VwoyjYjYNEbBE8AH87TduWP5uyDR1D+Iteg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", "dev": true, - "dependencies": { - "deep-equal": "^2.0.5" + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" } }, "node_modules/b4a": { @@ -16703,37 +16756,21 @@ } }, "node_modules/builtins": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/builtins/-/builtins-4.1.0.tgz", - "integrity": "sha512-1bPRZQtmKaO6h7qV1YHXNtr6nCK28k0Zo95KM4dXfILcZZwoHJBN1m3lfLv9LPkcOZlrSr+J1bzMaZFO98Yq0w==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/builtins/-/builtins-5.1.0.tgz", + "integrity": "sha512-SW9lzGTLvWTP1AY8xeAMZimqDrIaSdLQUcVr9DMef51niJ022Ri87SwRRKYm4A6iHfkPaiVUu/Duw2Wc4J7kKg==", "dev": true, - "peer": true, + "license": "MIT", "dependencies": { "semver": "^7.0.0" } }, - "node_modules/builtins/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "peer": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/builtins/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "dev": true, - "peer": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -16741,13 +16778,6 @@ "node": ">=10" } }, - "node_modules/builtins/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "peer": true - }, "node_modules/bull": { "version": "3.29.3", "resolved": "https://registry.npmjs.org/bull/-/bull-3.29.3.tgz", @@ -17299,50 +17329,6 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, - "node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/chalk/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/chalk/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/chalk/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -17997,6 +17983,13 @@ "proto-list": "~1.2.1" } }, + "node_modules/confusing-browser-globals": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz", + "integrity": "sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==", + "dev": true, + "license": "MIT" + }, "node_modules/connect-flash": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/connect-flash/-/connect-flash-0.1.1.tgz", @@ -18507,22 +18500,6 @@ "node": ">=0.8" } }, - "node_modules/cross-env": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-4.0.0.tgz", - "integrity": "sha512-dofkcyPqOy/AR14nbYSpk+TZ4IJZqg2as+/mQNkzh+7Xba2I1I1eyg/1G2dtSpD2LHjcEWwnGquiH2OP5LoeOw==", - "license": "MIT", - "dependencies": { - "cross-spawn": "^5.1.0", - "is-windows": "^1.0.0" - }, - "bin": { - "cross-env": "dist/bin/cross-env.js" - }, - "engines": { - "node": ">=4.0" - } - }, "node_modules/cross-fetch": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.8.tgz", @@ -20763,6 +20740,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-iterator-helpers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", + "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.3", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.6", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.4", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -20798,12 +20803,16 @@ } }, "node_modules/es-shim-unscopables": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz", - "integrity": "sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", "dev": true, + "license": "MIT", "dependencies": { - "has": "^1.0.3" + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" } }, "node_modules/es-to-primitive": { @@ -21004,15 +21013,17 @@ } }, "node_modules/eslint": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", - "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.0", - "@humanwhocodes/config-array": "^0.11.14", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "@ungap/structured-clone": "^1.2.0", @@ -21058,10 +21069,11 @@ } }, "node_modules/eslint-config-prettier": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.5.0.tgz", - "integrity": "sha512-obmWKLUNCnhtQRKc+tmnYuQl0pFU1ibYJQ5BGhTVB08bHe9wC8qUeG7c08dj9XX+AuPj1YSGSQIHl1pnDHZR0Q==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.10.0.tgz", + "integrity": "sha512-SM8AMJdeQqRYT9O9zguiruQZaN7+z+E4eAP9oiLNGKMtomwaB1E9dcgUD6ZAn/eQAb52USbvezbiljfZUhbJcg==", "dev": true, + "license": "MIT", "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -21070,9 +21082,9 @@ } }, "node_modules/eslint-config-standard": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/eslint-config-standard/-/eslint-config-standard-17.0.0.tgz", - "integrity": "sha512-/2ks1GKyqSOkH7JFvXJicu0iMpoojkwB+f5Du/1SC0PtBL+s8v30k9njRZ21pm2drKYm2342jFnGWzttxPmZVg==", + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/eslint-config-standard/-/eslint-config-standard-17.1.0.tgz", + "integrity": "sha512-IwHwmaBNtDK4zDHQukFDW5u/aTb8+meQWZvNFWkiGmbWjD6bqyuSSBxxXKkCftCUzc1zwCH2m/baCNDLGmuO5Q==", "dev": true, "funding": [ { @@ -21088,10 +21100,14 @@ "url": "https://feross.org/support" } ], + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "eslint": "^8.0.1", "eslint-plugin-import": "^2.25.2", - "eslint-plugin-n": "^15.0.0", + "eslint-plugin-n": "^15.0.0 || ^16.0.0 ", "eslint-plugin-promise": "^6.0.0" } }, @@ -21120,13 +21136,15 @@ } }, "node_modules/eslint-import-resolver-node": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz", - "integrity": "sha512-0En0w03NRVMn9Uiyn8YRPDKvWjxCWkslUEhGNTdGx15RvPJYQ+lbOlqrlNI2vEAs4pDYK4f/HN2TbDmk5TP0iw==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", "dev": true, + "license": "MIT", "dependencies": { "debug": "^3.2.7", - "resolve": "^1.20.0" + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" } }, "node_modules/eslint-import-resolver-node/node_modules/debug": { @@ -21139,16 +21157,21 @@ } }, "node_modules/eslint-module-utils": { - "version": "2.7.3", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.7.3.tgz", - "integrity": "sha512-088JEC7O3lDZM9xGe0RerkOMd0EjFl+Yvd1jPWIkMT5u3H9+HC34mWWPnqPrN13gieT9pBOO+Qt07Nb/6TresQ==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", "dev": true, + "license": "MIT", "dependencies": { - "debug": "^3.2.7", - "find-up": "^2.1.0" + "debug": "^3.2.7" }, "engines": { "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, "node_modules/eslint-module-utils/node_modules/debug": { @@ -21160,90 +21183,25 @@ "ms": "^2.1.1" } }, - "node_modules/eslint-module-utils/node_modules/find-up": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", - "integrity": "sha1-RdG35QbHF93UgndaK3eSCjwMV6c=", - "dev": true, - "dependencies": { - "locate-path": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/eslint-module-utils/node_modules/locate-path": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", - "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=", - "dev": true, - "dependencies": { - "p-locate": "^2.0.0", - "path-exists": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/eslint-module-utils/node_modules/p-limit": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", - "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", - "dev": true, - "dependencies": { - "p-try": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/eslint-module-utils/node_modules/p-locate": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", - "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=", - "dev": true, - "dependencies": { - "p-limit": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/eslint-module-utils/node_modules/p-try": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", - "integrity": "sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/eslint-module-utils/node_modules/path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/eslint-plugin-chai-expect": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-chai-expect/-/eslint-plugin-chai-expect-3.0.0.tgz", - "integrity": "sha512-NS0YBcToJl+BRKBSMCwRs/oHJIX67fG5Gvb4tGked+9Wnd1/PzKijd82B2QVKcSSOwRe+pp4RAJ2AULeck4eQw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-chai-expect/-/eslint-plugin-chai-expect-3.1.0.tgz", + "integrity": "sha512-a9F8b38hhJsR7fgDEfyMxppZXCnCW6OOHj7cQfygsm9guXqdSzfpwrHX5FT93gSExDqD71HQglF1lLkGBwhJ+g==", "dev": true, + "license": "MIT", "engines": { - "node": "10.* || 12.* || >= 14.*" + "node": "10.* || 12.* || || 14.* || 16.* || >= 18.*" }, "peerDependencies": { - "eslint": ">=2.0.0 <= 8.x" + "eslint": ">=2.0.0 <= 9.x" } }, "node_modules/eslint-plugin-chai-friendly": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-chai-friendly/-/eslint-plugin-chai-friendly-0.7.2.tgz", - "integrity": "sha512-LOIfGx5sZZ5FwM1shr2GlYAWV9Omdi+1/3byuVagvQNoGUuU0iHhp7AfjA1uR+4dJ4Isfb4+FwBJgQajIw9iAg==", + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-chai-friendly/-/eslint-plugin-chai-friendly-0.7.4.tgz", + "integrity": "sha512-PGPjJ8diYgX1mjLxGJqRop2rrGwZRKImoEOwUOgoIhg0p80MkTaqvmFLe5TF7/iagZHggasvIfQlUyHIhK/PYg==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" }, @@ -21252,10 +21210,11 @@ } }, "node_modules/eslint-plugin-cypress": { - "version": "2.15.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-cypress/-/eslint-plugin-cypress-2.15.1.tgz", - "integrity": "sha512-eLHLWP5Q+I4j2AWepYq0PgFEei9/s5LvjuSqWrxurkg1YZ8ltxdvMNmdSf0drnsNo57CTgYY/NIHHLRSWejR7w==", + "version": "2.15.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-cypress/-/eslint-plugin-cypress-2.15.2.tgz", + "integrity": "sha512-CtcFEQTDKyftpI22FVGpx8bkpKyYXBlNge6zSo0pl5/qJvBAnzaD76Vu2AsP16d6mTj478Ldn2mhgrWV+Xr0vQ==", "dev": true, + "license": "MIT", "dependencies": { "globals": "^13.20.0" }, @@ -21264,10 +21223,11 @@ } }, "node_modules/eslint-plugin-cypress/node_modules/globals": { - "version": "13.23.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.23.0.tgz", - "integrity": "sha512-XAmF0RjlrjY23MA51q3HltdlGxUpXPvg0GioKiD9X6HD28iMjo2dKC8Vqwm7lne4GNr78+RHTfliktR6ZH09wA==", + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, + "license": "MIT", "dependencies": { "type-fest": "^0.20.2" }, @@ -21283,7 +21243,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-es/-/eslint-plugin-es-4.1.0.tgz", "integrity": "sha512-GILhQTnjYE2WorX5Jyi5i4dz5ALWxBIdQECVQavL6s7cI76IZTDWleTHkxz/QT3kvcs2QlGHvKLYsSlPOlPXnQ==", "dev": true, - "peer": true, "dependencies": { "eslint-utils": "^2.0.0", "regexpp": "^3.0.0" @@ -21299,39 +21258,47 @@ } }, "node_modules/eslint-plugin-import": { - "version": "2.26.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.26.0.tgz", - "integrity": "sha512-hYfi3FXaM8WPLf4S1cikh/r4IxnO6zrhZbEGz2b660EJRbuxgpDS5gkCuYgGWg2xxh2rBuIr4Pvhve/7c31koA==", + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, + "license": "MIT", "dependencies": { - "array-includes": "^3.1.4", - "array.prototype.flat": "^1.2.5", - "debug": "^2.6.9", + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.6", - "eslint-module-utils": "^2.7.3", - "has": "^1.0.3", - "is-core-module": "^2.8.1", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", - "object.values": "^1.1.5", - "resolve": "^1.22.0", - "tsconfig-paths": "^3.14.1" + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" }, "engines": { "node": ">=4" }, "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8" + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "node_modules/eslint-plugin-import/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, + "license": "MIT", "dependencies": { - "ms": "2.0.0" + "ms": "^2.1.1" } }, "node_modules/eslint-plugin-import/node_modules/doctrine": { @@ -21339,6 +21306,7 @@ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", "dev": true, + "license": "Apache-2.0", "dependencies": { "esutils": "^2.0.2" }, @@ -21346,56 +21314,63 @@ "node": ">=0.10.0" } }, - "node_modules/eslint-plugin-import/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true - }, "node_modules/eslint-plugin-jsx-a11y": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.7.1.tgz", - "integrity": "sha512-63Bog4iIethyo8smBklORknVjB0T2dwB8Mr/hIC+fBS0uyHdYYpzM/Ed+YC8VxTjlXHEWFOdmgwcDn1U2L9VCA==", + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.20.7", - "aria-query": "^5.1.3", - "array-includes": "^3.1.6", - "array.prototype.flatmap": "^1.3.1", - "ast-types-flow": "^0.0.7", - "axe-core": "^4.6.2", - "axobject-query": "^3.1.1", + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", "damerau-levenshtein": "^1.0.8", "emoji-regex": "^9.2.2", - "has": "^1.0.3", - "jsx-ast-utils": "^3.3.3", - "language-tags": "=1.0.5", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", "minimatch": "^3.1.2", - "object.entries": "^1.1.6", - "object.fromentries": "^2.0.6", - "semver": "^6.3.0" + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" }, "engines": { "node": ">=4.0" }, "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" } }, "node_modules/eslint-plugin-jsx-a11y/node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/eslint-plugin-mocha": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-mocha/-/eslint-plugin-mocha-10.1.0.tgz", - "integrity": "sha512-xLqqWUF17llsogVOC+8C6/jvQ+4IoOREbN7ZCHuOHuD6cT5cDD4h7f2LgsZuzMAiwswWE21tO7ExaknHVDrSkw==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-mocha/-/eslint-plugin-mocha-10.5.0.tgz", + "integrity": "sha512-F2ALmQVPT1GoP27O1JTZGrV9Pqg8k79OeIuvw63UxMtQKREZtmkK1NFgkZQ2TW7L2JSSFKHFPTtHu5z8R9QNRw==", "dev": true, + "license": "MIT", "dependencies": { "eslint-utils": "^3.0.0", - "rambda": "^7.1.0" + "globals": "^13.24.0", + "rambda": "^7.4.0" }, "engines": { "node": ">=14.0.0" @@ -21409,6 +21384,7 @@ "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", "dev": true, + "license": "MIT", "dependencies": { "eslint-visitor-keys": "^2.0.0" }, @@ -21422,21 +21398,37 @@ "eslint": ">=5" } }, - "node_modules/eslint-plugin-n": { - "version": "15.2.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-15.2.0.tgz", - "integrity": "sha512-lWLg++jGwC88GDGGBX3CMkk0GIWq0y41aH51lavWApOKcMQcYoL3Ayd0lEdtD3SnQtR+3qBvWQS3qGbR2BxRWg==", + "node_modules/eslint-plugin-mocha/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, - "peer": true, + "license": "MIT", "dependencies": { - "builtins": "^4.0.0", + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-plugin-n": { + "version": "15.7.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-15.7.0.tgz", + "integrity": "sha512-jDex9s7D/Qial8AGVIHq4W7NswpUD5DPDL2RH8Lzd9EloWUuvUkHfv4FRLMipH5q2UtyurorBkPeNi1wVWNh3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "builtins": "^5.0.1", "eslint-plugin-es": "^4.1.0", "eslint-utils": "^3.0.0", "ignore": "^5.1.1", - "is-core-module": "^2.3.0", - "minimatch": "^3.0.4", - "resolve": "^1.10.1", - "semver": "^6.3.0" + "is-core-module": "^2.11.0", + "minimatch": "^3.1.2", + "resolve": "^1.22.1", + "semver": "^7.3.8" }, "engines": { "node": ">=12.22.0" @@ -21453,7 +21445,6 @@ "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", "dev": true, - "peer": true, "dependencies": { "eslint-visitor-keys": "^2.0.0" }, @@ -21467,55 +21458,30 @@ "eslint": ">=5" } }, - "node_modules/eslint-plugin-node": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-node/-/eslint-plugin-node-11.1.0.tgz", - "integrity": "sha512-oUwtPJ1W0SKD0Tr+wqu92c5xuCeQqB3hSCHasn/ZgjFdA9iDGNkNf2Zi9ztY7X+hNuMib23LNGRm6+uN+KLE3g==", + "node_modules/eslint-plugin-n/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "dev": true, - "dependencies": { - "eslint-plugin-es": "^3.0.0", - "eslint-utils": "^2.0.0", - "ignore": "^5.1.1", - "minimatch": "^3.0.4", - "resolve": "^1.10.1", - "semver": "^6.1.0" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": ">=8.10.0" - }, - "peerDependencies": { - "eslint": ">=5.16.0" - } - }, - "node_modules/eslint-plugin-node/node_modules/eslint-plugin-es": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-es/-/eslint-plugin-es-3.0.1.tgz", - "integrity": "sha512-GUmAsJaN4Fc7Gbtl8uOBlayo2DqhwWvEzykMHSCZHU3XdJ+NSzzZcVhXh3VxX5icqQ+oQdIEawXX8xkR3mIFmQ==", - "dev": true, - "dependencies": { - "eslint-utils": "^2.0.0", - "regexpp": "^3.0.0" - }, - "engines": { - "node": ">=8.10.0" - }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" - }, - "peerDependencies": { - "eslint": ">=4.19.1" + "node": ">=10" } }, "node_modules/eslint-plugin-prettier": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.0.0.tgz", - "integrity": "sha512-98MqmCJ7vJodoQK359bqQWaxOE0CS8paAz/GgjaZLyex4TTk3g9HugoO89EqWCrFiOqn9EVvcoo7gZzONCWVwQ==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz", + "integrity": "sha512-f/0rXLXUt0oFYs8ra4w49wYZBG5GKZpAYsJSm6rnYL5uVDjd+zowwMwVZHnAjf4edNrKpCDYfXDgmRE/Ak7QyQ==", "dev": true, + "license": "MIT", "dependencies": { "prettier-linter-helpers": "^1.0.0" }, "engines": { - "node": ">=6.0.0" + "node": ">=12.0.0" }, "peerDependencies": { "eslint": ">=7.28.0", @@ -21528,51 +21494,60 @@ } }, "node_modules/eslint-plugin-promise": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-6.0.0.tgz", - "integrity": "sha512-7GPezalm5Bfi/E22PnQxDWH2iW9GTvAlUNTztemeHb6c1BniSyoeTrM87JkC0wYdi6aQrZX9p2qEiAno8aTcbw==", + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-6.6.0.tgz", + "integrity": "sha512-57Zzfw8G6+Gq7axm2Pdo3gW/Rx3h9Yywgn61uE/3elTCOePEHVrn2i5CdfBwA1BLK0Q0WqctICIUSqXZW/VprQ==", "dev": true, + "license": "ISC", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, + "funding": { + "url": "https://opencollective.com/eslint" + }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" } }, "node_modules/eslint-plugin-react": { - "version": "7.32.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.32.2.tgz", - "integrity": "sha512-t2fBMa+XzonrrNkyVirzKlvn5RXzzPwRHtMvLAtVZrt8oxgnTQaYbU6SXTOO1mwQgp1y5+toMSKInnzGr0Knqg==", + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", "dev": true, + "license": "MIT", "dependencies": { - "array-includes": "^3.1.6", - "array.prototype.flatmap": "^1.3.1", - "array.prototype.tosorted": "^1.1.1", + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", + "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", - "object.entries": "^1.1.6", - "object.fromentries": "^2.0.6", - "object.hasown": "^1.1.2", - "object.values": "^1.1.6", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", "prop-types": "^15.8.1", - "resolve": "^2.0.0-next.4", - "semver": "^6.3.0", - "string.prototype.matchall": "^4.0.8" + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" }, "engines": { "node": ">=4" }, "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "node_modules/eslint-plugin-react-hooks": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz", - "integrity": "sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==", + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", + "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -21585,6 +21560,7 @@ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", "dev": true, + "license": "Apache-2.0", "dependencies": { "esutils": "^2.0.2" }, @@ -21593,12 +21569,13 @@ } }, "node_modules/eslint-plugin-react/node_modules/resolve": { - "version": "2.0.0-next.4", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.4.tgz", - "integrity": "sha512-iMDbmAWtfU+MHpxt/I5iWI7cY6YVEZUQ3MBgPQ++XD1PELuJHIl82xBmObyP2KyQmkNB2dsqF7seoQQiAn5yDQ==", + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", "dev": true, + "license": "MIT", "dependencies": { - "is-core-module": "^2.9.0", + "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -21610,9 +21587,9 @@ } }, "node_modules/eslint-plugin-testing-library": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-testing-library/-/eslint-plugin-testing-library-7.1.1.tgz", - "integrity": "sha512-nszC833aZPwB6tik1nMkbFqmtgIXTT0sfJEYs0zMBKMlkQ4to2079yUV96SvmLh00ovSBJI4pgcBC1TiIP8mXg==", + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-testing-library/-/eslint-plugin-testing-library-7.5.3.tgz", + "integrity": "sha512-sZk5hIrx0p1ehvdS2qHefKwXHiEysiQN+FMGCzES6xRNUgwI3q4KdWMeAwpPDP9u0RDkNzJpebRUnNch1sJh+A==", "dev": true, "license": "MIT", "dependencies": { @@ -21628,14 +21605,14 @@ } }, "node_modules/eslint-plugin-testing-library/node_modules/@typescript-eslint/scope-manager": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.21.0.tgz", - "integrity": "sha512-G3IBKz0/0IPfdeGRMbp+4rbjfSSdnGkXsM/pFZA8zM9t9klXDnB/YnKOBQ0GoPmoROa4bCq2NeHgJa5ydsQ4mA==", + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.35.1.tgz", + "integrity": "sha512-s/Bpd4i7ht2934nG+UoSPlYXd08KYz3bmjLEb7Ye1UVob0d1ENiT3lY8bsCmik4RqfSbPw9xJJHbugpPpP5JUg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.21.0", - "@typescript-eslint/visitor-keys": "8.21.0" + "@typescript-eslint/types": "8.35.1", + "@typescript-eslint/visitor-keys": "8.35.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -21646,9 +21623,9 @@ } }, "node_modules/eslint-plugin-testing-library/node_modules/@typescript-eslint/types": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.21.0.tgz", - "integrity": "sha512-PAL6LUuQwotLW2a8VsySDBwYMm129vFm4tMVlylzdoTybTHaAi0oBp7Ac6LhSrHHOdLM3efH+nAR6hAWoMF89A==", + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.1.tgz", + "integrity": "sha512-q/O04vVnKHfrrhNAscndAn1tuQhIkwqnaW+eu5waD5IPts2eX1dgJxgqcPx5BX109/qAz7IG6VrEPTOYKCNfRQ==", "dev": true, "license": "MIT", "engines": { @@ -21659,66 +21636,15 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/eslint-plugin-testing-library/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.21.0.tgz", - "integrity": "sha512-x+aeKh/AjAArSauz0GiQZsjT8ciadNMHdkUSwBB9Z6PrKc/4knM4g3UfHml6oDJmKC88a6//cdxnO/+P2LkMcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.21.0", - "@typescript-eslint/visitor-keys": "8.21.0", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <5.8.0" - } - }, - "node_modules/eslint-plugin-testing-library/node_modules/@typescript-eslint/utils": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.21.0.tgz", - "integrity": "sha512-xcXBfcq0Kaxgj7dwejMbFyq7IOHgpNMtVuDveK7w3ZGwG9owKzhALVwKpTF2yrZmEwl9SWdetf3fxNzJQaVuxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.21.0", - "@typescript-eslint/types": "8.21.0", - "@typescript-eslint/typescript-estree": "8.21.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" - } - }, "node_modules/eslint-plugin-testing-library/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.21.0.tgz", - "integrity": "sha512-BkLMNpdV6prozk8LlyK/SOoWLmUFi+ZD+pcqti9ILCbVvHGk1ui1g4jJOc2WDLaeExz2qWwojxlPce5PljcT3w==", + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.35.1.tgz", + "integrity": "sha512-VRwixir4zBWCSTP/ljEo091lbpypz57PoeAQ9imjG+vbeof9LplljsL1mos4ccG6H9IjfrVGM359RozUnuFhpw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.21.0", - "eslint-visitor-keys": "^4.2.0" + "@typescript-eslint/types": "8.35.1", + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -21728,38 +21654,10 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/eslint-plugin-testing-library/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/eslint-plugin-testing-library/node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/eslint-plugin-testing-library/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -21769,53 +21667,12 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint-plugin-testing-library/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/eslint-plugin-testing-library/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/eslint-plugin-testing-library/node_modules/ts-api-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.0.tgz", - "integrity": "sha512-xCt/TOAc+EOHS1XPnijD3/yzpH6qg2xppZO1YDqGoVsNXfQfzHpOdNuXwrwOU8u4ITXJyDCTyt8w5g1sZv9ynQ==", + "node_modules/eslint-plugin-unicorn": { + "version": "56.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-56.0.1.tgz", + "integrity": "sha512-FwVV0Uwf8XPfVnKSGpMg7NtlZh0G0gBarCaFcMUOoqPxXryxdYxTRRv4kH6B9TFCVIrjRXG+emcxIk2ayZilog==", "dev": true, "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/eslint-plugin-unicorn": { - "version": "56.0.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-56.0.0.tgz", - "integrity": "sha512-aXpddVz/PQMmd69uxO98PA4iidiVNvA0xOtbpUoz1WhBd4RxOQQYqN618v68drY0hmy5uU2jy1bheKEVWBjlPw==", - "dev": true, "dependencies": { "@babel/helper-validator-identifier": "^7.24.7", "@eslint-community/eslint-utils": "^4.4.0", @@ -21845,9 +21702,9 @@ } }, "node_modules/eslint-plugin-unicorn/node_modules/ci-info": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.0.0.tgz", - "integrity": "sha512-TdHqgGf9odd8SXNuxtUBVx8Nv+qZOejE6qyqiy5NtbYYQOeFa6zmHkxlPzmaLxWWHsU6nJmB7AETdVPi+2NBUg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.2.0.tgz", + "integrity": "sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg==", "dev": true, "funding": [ { @@ -21855,15 +21712,17 @@ "url": "https://github.com/sponsors/sibiraj-s" } ], + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/eslint-plugin-unicorn/node_modules/globals": { - "version": "15.11.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.11.0.tgz", - "integrity": "sha512-yeyNSjdbyVaWurlwCpcA6XNBrHTMIeDdj0/hnvX/OLJ9ekOXYbLsLinH/MucQyGvNnXhidTdNhTtJaffL2sMfw==", + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -21876,6 +21735,7 @@ "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.10.0.tgz", "integrity": "sha512-qx+xQGZVsy55CH0a1hiVwHmqjLryfh7wQyF5HO07XJ9f7dQMY/gPQHhlyDkIzJKC+x2fUCpCcUODUUUFrm7SHA==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "jsesc": "~0.5.0" }, @@ -21893,10 +21753,11 @@ } }, "node_modules/eslint-plugin-unicorn/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -21909,6 +21770,7 @@ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", "dev": true, + "license": "MIT", "dependencies": { "min-indent": "^1.0.0" }, @@ -21975,6 +21837,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -21989,6 +21852,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -22004,6 +21868,7 @@ "version": "7.2.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -22019,6 +21884,7 @@ "version": "3.4.3", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "license": "Apache-2.0", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -22030,6 +21896,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "license": "ISC", "dependencies": { "is-glob": "^4.0.3" }, @@ -22038,9 +21905,10 @@ } }, "node_modules/eslint/node_modules/globals": { - "version": "13.23.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.23.0.tgz", - "integrity": "sha512-XAmF0RjlrjY23MA51q3HltdlGxUpXPvg0GioKiD9X6HD28iMjo2dKC8Vqwm7lne4GNr78+RHTfliktR6ZH09wA==", + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "license": "MIT", "dependencies": { "type-fest": "^0.20.2" }, @@ -22051,26 +21919,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint/node_modules/optionator": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", - "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", - "dependencies": { - "@aashutoshrathi/word-wrap": "^1.2.3", - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/eslint/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -23832,12 +23685,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/functional-red-black-tree": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", - "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", - "dev": true - }, "node_modules/functions-have-names": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", @@ -24402,17 +24249,6 @@ } } }, - "node_modules/google-gax/node_modules/duplexify": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.2.tgz", - "integrity": "sha512-fz3OjcNCHmRP12MJoZMPglx8m4rrFP8rovnk4vT8Fs+aonZoCwGg10dSsQsfP/E62eZcPTMSMP6686fu9Qlqtw==", - "dependencies": { - "end-of-stream": "^1.4.1", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1", - "stream-shift": "^1.0.0" - } - }, "node_modules/google-gax/node_modules/gaxios": { "version": "6.6.0", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.6.0.tgz", @@ -24992,18 +24828,6 @@ "node": ">=6" } }, - "node_modules/has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.1" - }, - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/has-bigints": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", @@ -26310,11 +26134,15 @@ } }, "node_modules/is-core-module": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", - "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", "dependencies": { - "hasown": "^2.0.0" + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -26860,6 +26688,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -27035,6 +26864,24 @@ "node": ">=8" } }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -27818,13 +27665,16 @@ } }, "node_modules/jsx-ast-utils": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.3.tgz", - "integrity": "sha512-fYQHZTZ8jSfmWZ0iyzfwiU4WDX4HpHbMCZ3gPlWYiCl3BoeOTsqKBqnTVfH2rYT7eP5c3sVbeSPHnnJOaTrWiw==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", "dev": true, + "license": "MIT", "dependencies": { - "array-includes": "^3.1.5", - "object.assign": "^4.1.3" + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" }, "engines": { "node": ">=4.0" @@ -28007,12 +27857,16 @@ "dev": true }, "node_modules/language-tags": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.5.tgz", - "integrity": "sha1-0yHbxNowuovzAk4ED6XBRmH5GTo=", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", "dev": true, + "license": "MIT", "dependencies": { - "language-subtag-registry": "~0.3.2" + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" } }, "node_modules/latexqc": { @@ -28737,7 +28591,8 @@ "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", - "dev": true + "dev": true, + "peer": true }, "node_modules/lodash.union": { "version": "4.6.0", @@ -31239,28 +31094,32 @@ } }, "node_modules/object.entries": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.7.tgz", - "integrity": "sha512-jCBs/0plmPsOnrKAfFQXRG2NFjlhZgjjcBLSmTnEhU8U6vVTsVe8ANeQJCHTl3gSsI4J+0emOoCgoKlmQPMgmA==", + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1" + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" }, "engines": { "node": ">= 0.4" } }, "node_modules/object.fromentries": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.6.tgz", - "integrity": "sha512-VciD13dswC4j1Xt5394WR4MzmAQmlgN72phd/riNp9vtD7tp4QQWJ0R4wvclXcafgcYK8veHRed2W6XeGBvcfg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -31285,17 +31144,19 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object.hasown": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/object.hasown/-/object.hasown-1.1.2.tgz", - "integrity": "sha512-B5UIT3J1W+WuWIU55h0mjlwaqxiE5vYENJXIXZ4VFe05pNYrkKuK0U/6aFcb0pKywYJh7IhfoqUfKVmrJJHZHw==", + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", "dev": true, + "license": "MIT", "dependencies": { - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">= 0.4" } }, "node_modules/object.pick": { @@ -31311,14 +31172,16 @@ } }, "node_modules/object.values": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.6.tgz", - "integrity": "sha512-FVVTkD1vENCsAcwNs9k6jea2uHC/X0+JcjG8YA60FN5CMaJmG95wT9jek/xX9nornqGRrBkKtzuAu2wuHpKqvw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -31439,6 +31302,23 @@ "node": ">=0.10" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/options": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/options/-/options-0.0.6.tgz", @@ -33977,10 +33857,11 @@ } }, "node_modules/prettier": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", - "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, + "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" }, @@ -34087,15 +33968,6 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, - "node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "dev": true, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/prom-client": { "version": "14.1.1", "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-14.1.1.tgz", @@ -38046,25 +37918,60 @@ "node": ">=8" } }, - "node_modules/string.prototype.matchall": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.8.tgz", - "integrity": "sha512-6zOCOcJ+RJAQshcTvXPHoxoQGONa3e/Lqx90wUA+wEzX78sg5Bo+1tQo4N0pohS0erG9qtCqJDjNCQBjeWVxyg==", + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "get-intrinsic": "^1.1.3", - "has-symbols": "^1.0.3", - "internal-slot": "^1.0.3", - "regexp.prototype.flags": "^1.4.3", - "side-channel": "^1.0.4" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, "node_modules/string.prototype.trim": { "version": "1.2.10", "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", @@ -38912,11 +38819,6 @@ "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=" }, - "node_modules/swagger-client/node_modules/traverse": { - "version": "0.6.6", - "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.6.tgz", - "integrity": "sha1-y99WD9e5r2MlAv7UD5GMFX6pcTc=" - }, "node_modules/swagger-client/node_modules/url": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz", @@ -39020,6 +38922,7 @@ "resolved": "https://registry.npmjs.org/table/-/table-6.8.2.tgz", "integrity": "sha512-w2sfv80nrAh2VCbqR5AK27wswXhqcck2AhfnNW76beQXskGZ1V12GwS//yYVa3d3fcvAip2OUnbDAjW2k3v9fA==", "dev": true, + "peer": true, "dependencies": { "ajv": "^8.0.1", "lodash.truncate": "^4.4.2", @@ -39036,6 +38939,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -39051,7 +38955,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true + "dev": true, + "peer": true }, "node_modules/tapable": { "version": "2.2.1", @@ -39961,13 +39866,14 @@ } }, "node_modules/tsconfig-paths": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz", - "integrity": "sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", "dev": true, + "license": "MIT", "dependencies": { "@types/json5": "^0.0.29", - "json5": "^1.0.1", + "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } @@ -40170,10 +40076,11 @@ } }, "node_modules/typescript": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", - "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -40707,12 +40614,6 @@ "uuid": "bin/uuid" } }, - "node_modules/v8-compile-cache": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.4.0.tgz", - "integrity": "sha512-ocyWc3bAHBB/guyqJQVI5o4BZkPhznPYUG2ea80Gond/BgNWpap8TOmLSeeQG7bnh2KMISxskdADG59j7zruhw==", - "dev": true - }, "node_modules/v8-to-istanbul": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.1.0.tgz", @@ -42131,7 +42032,6 @@ "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -42687,6 +42587,7 @@ "@overleaf/o-error": "*", "@overleaf/promise-utils": "*", "@overleaf/settings": "*", + "@overleaf/stream-utils": "*", "archiver": "5.3.2", "async": "^3.2.5", "body-parser": "^1.20.3", @@ -42701,7 +42602,6 @@ "workerpool": "^6.1.5" }, "devDependencies": { - "@types/workerpool": "^6.1.0", "chai": "^4.3.6", "chai-as-promised": "^7.1.1", "mocha": "^11.1.0", @@ -42723,6 +42623,7 @@ "@overleaf/o-error": "*", "@overleaf/promise-utils": "*", "@overleaf/settings": "*", + "@overleaf/stream-utils": "*", "body-parser": "^1.20.3", "bunyan": "^1.8.15", "celebrate": "^15.0.3", @@ -43480,7 +43381,7 @@ "bootstrap": "^5.3.3", "compression": "^1.7.1", "cookie-parser": "^1.4.6", - "cross-env": "^4.0.0", + "cross-env": "^7.0.3", "es6-promise": "^4.2.8", "express": "^4.21.2", "express-basic-auth": "^1.2.0", @@ -43522,11 +43423,11 @@ "combobo": "^2.0.4", "css-loader": "^6.8.1", "cssnano": "^6.0.0", - "eslint": "^7.21.0", - "eslint-config-prettier": "^8.5.0", - "eslint-config-standard": "^16.0.3", + "eslint": "^8.57.0", + "eslint-config-prettier": "^10.1.5", + "eslint-config-standard": "^17.0.0", "eslint-plugin-react": "^7.32.2", - "eslint-plugin-unicorn": "^56.0.0", + "eslint-plugin-unicorn": "^56.0.1", "file-loader": "^6.2.0", "mini-css-extract-plugin": "^2.7.6", "nodemon": "^3.0.1", @@ -43551,50 +43452,6 @@ "node": ">=18" } }, - "services/latexqc/node_modules/@babel/code-frame": { - "version": "7.12.11", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz", - "integrity": "sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==", - "dev": true, - "dependencies": { - "@babel/highlight": "^7.10.4" - } - }, - "services/latexqc/node_modules/@eslint/eslintrc": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.3.tgz", - "integrity": "sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw==", - "dev": true, - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.1.1", - "espree": "^7.3.0", - "globals": "^13.9.0", - "ignore": "^4.0.6", - "import-fresh": "^3.2.1", - "js-yaml": "^3.13.1", - "minimatch": "^3.0.4", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "services/latexqc/node_modules/@humanwhocodes/config-array": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.5.0.tgz", - "integrity": "sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg==", - "deprecated": "Use @eslint/config-array instead", - "dev": true, - "dependencies": { - "@humanwhocodes/object-schema": "^1.2.0", - "debug": "^4.1.1", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=10.10.0" - } - }, "services/latexqc/node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -43765,30 +43622,6 @@ "node": ">=0.4.0" } }, - "services/latexqc/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "services/latexqc/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, "services/latexqc/node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -43843,22 +43676,6 @@ "node": ">=12" } }, - "services/latexqc/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "services/latexqc/node_modules/check-error": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", @@ -43869,6 +43686,24 @@ "node": ">= 16" } }, + "services/latexqc/node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "services/latexqc/node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", @@ -43918,123 +43753,20 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "services/latexqc/node_modules/eslint": { - "version": "7.32.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.32.0.tgz", - "integrity": "sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==", + "services/latexqc/node_modules/eslint-config-prettier": { + "version": "10.1.5", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.5.tgz", + "integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==", "dev": true, - "dependencies": { - "@babel/code-frame": "7.12.11", - "@eslint/eslintrc": "^0.4.3", - "@humanwhocodes/config-array": "^0.5.0", - "ajv": "^6.10.0", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.0.1", - "doctrine": "^3.0.0", - "enquirer": "^2.3.5", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^5.1.1", - "eslint-utils": "^2.1.0", - "eslint-visitor-keys": "^2.0.0", - "espree": "^7.3.1", - "esquery": "^1.4.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "functional-red-black-tree": "^1.0.1", - "glob-parent": "^5.1.2", - "globals": "^13.6.0", - "ignore": "^4.0.6", - "import-fresh": "^3.0.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "js-yaml": "^3.13.1", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.0.4", - "natural-compare": "^1.4.0", - "optionator": "^0.9.1", - "progress": "^2.0.0", - "regexpp": "^3.1.0", - "semver": "^7.2.1", - "strip-ansi": "^6.0.0", - "strip-json-comments": "^3.1.0", - "table": "^6.0.9", - "text-table": "^0.2.0", - "v8-compile-cache": "^2.0.3" - }, + "license": "MIT", "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" + "eslint-config-prettier": "bin/cli.js" }, "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "services/latexqc/node_modules/eslint-config-standard": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/eslint-config-standard/-/eslint-config-standard-16.0.3.tgz", - "integrity": "sha512-x4fmJL5hGqNJKGHSjnLdgA6U6h1YW/G2dW9fA+cyVur4SK6lyue8+UgNKWlZtUDTXvgKDD/Oa3GQjmB5kjtVvg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "peerDependencies": { - "eslint": "^7.12.1", - "eslint-plugin-import": "^2.22.1", - "eslint-plugin-node": "^11.1.0", - "eslint-plugin-promise": "^4.2.1 || ^5.0.0" - } - }, - "services/latexqc/node_modules/eslint-plugin-promise": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-5.2.0.tgz", - "integrity": "sha512-SftLb1pUG01QYq2A/hGAWfDRXqYD82zE7j7TopDOyNdU+7SvvoXREls/+PRTY17vUXzXnZA/zfnyKgRH6x4JJw==", - "dev": true, - "peer": true, - "engines": { - "node": "^10.12.0 || >=12.0.0" + "url": "https://opencollective.com/eslint-config-prettier" }, "peerDependencies": { - "eslint": "^7.0.0" - } - }, - "services/latexqc/node_modules/espree": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-7.3.1.tgz", - "integrity": "sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==", - "dev": true, - "dependencies": { - "acorn": "^7.4.0", - "acorn-jsx": "^5.3.1", - "eslint-visitor-keys": "^1.3.0" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "services/latexqc/node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", - "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", - "dev": true, - "engines": { - "node": ">=4" + "eslint": ">=7.0.0" } }, "services/latexqc/node_modules/fdir": { @@ -44052,21 +43784,6 @@ } } }, - "services/latexqc/node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "services/latexqc/node_modules/helmet": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.1.0.tgz", @@ -44090,15 +43807,6 @@ "node": ">= 6" } }, - "services/latexqc/node_modules/ignore": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", - "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", - "dev": true, - "engines": { - "node": ">= 4" - } - }, "services/latexqc/node_modules/jiti": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", @@ -44111,19 +43819,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "services/latexqc/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "services/latexqc/node_modules/jsdom": { "version": "20.0.3", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", @@ -44225,23 +43920,6 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, - "services/latexqc/node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "services/latexqc/node_modules/parse5": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", @@ -44394,30 +44072,6 @@ "ajv": "^8.8.2" } }, - "services/latexqc/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "services/latexqc/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "services/latexqc/node_modules/tinyrainbow": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", @@ -45152,6 +44806,7 @@ "@overleaf/promise-utils": "*", "@overleaf/redis-wrapper": "*", "@overleaf/settings": "*", + "@overleaf/stream-utils": "*", "@phosphor-icons/react": "^2.1.7", "@slack/webhook": "^7.0.2", "@stripe/stripe-js": "^7.3.0", @@ -45272,6 +44927,7 @@ "@pollyjs/adapter-node-http": "^6.0.6", "@pollyjs/core": "^6.0.6", "@pollyjs/persister-fs": "^6.0.6", + "@prettier/plugin-pug": "^3.4.0", "@replit/codemirror-emacs": "overleaf/codemirror-emacs#4394c03858f27053f8768258e9493866e06e938e", "@replit/codemirror-indentation-markers": "overleaf/codemirror-indentation-markers#78264032eb286bc47871569ae87bff5ca1c6c161", "@replit/codemirror-vim": "overleaf/codemirror-vim#1bef138382d948018f3f9b8a4d7a70ab61774e4b", @@ -45300,7 +44956,6 @@ "@types/mocha": "^9.1.0", "@types/mocha-each": "^2.0.0", "@types/react": "^18.3.20", - "@types/react-bootstrap": "^0.32.37", "@types/react-color": "^3.0.13", "@types/react-dom": "^18.3.6", "@types/react-google-recaptcha": "^2.1.9", @@ -45338,6 +44993,7 @@ "chartjs-plugin-datalabels": "^2.2.0", "cheerio": "^1.0.0-rc.3", "classnames": "^2.2.6", + "confusing-browser-globals": "^1.0.11", "cookie-signature": "^1.2.1", "copy-webpack-plugin": "^11.0.0", "core-js": "^3.41.0", @@ -45364,6 +45020,7 @@ "formik": "^2.2.9", "fuse.js": "^3.0.0", "glob": "^7.1.6", + "globals": "^16.2.0", "handlebars": "^4.7.8", "handlebars-loader": "^1.7.3", "html-webpack-plugin": "^5.5.3", @@ -45420,7 +45077,7 @@ "timekeeper": "^2.2.0", "to-string-loader": "^1.2.0", "tty-browserify": "^0.0.1", - "typescript": "^5.0.4", + "typescript": "^5.8.3", "uuid": "^9.0.1", "vitest": "^3.1.2", "w3c-keyname": "^2.2.8", @@ -45432,26 +45089,6 @@ "yup": "^0.32.11" } }, - "services/web/node_modules/@eslint-community/eslint-utils": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", - "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, "services/web/node_modules/@google-cloud/bigquery": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@google-cloud/bigquery/-/bigquery-6.0.3.tgz", @@ -45650,13 +45287,6 @@ "node": ">=12.16" } }, - "services/web/node_modules/@transloadit/prettier-bytes": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@transloadit/prettier-bytes/-/prettier-bytes-0.0.9.tgz", - "integrity": "sha512-pCvdmea/F3Tn4hAtHqNXmjcixSaroJJ+L3STXlYJdir1g1m2mRQpWbN8a4SvgQtaw2930Ckhdx8qXdXBFMKbAA==", - "dev": true, - "license": "MIT" - }, "services/web/node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -45824,23 +45454,6 @@ "url": "https://opencollective.com/eslint" } }, - "services/web/node_modules/@uppy/core": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@uppy/core/-/core-3.8.0.tgz", - "integrity": "sha512-C93vVhid929+VLGjaD9CZOLJDg8GkEGMUGveFp3Tyo/wujiG+sB3fOF+c6TzKpzPLfNtVpskU1BnI7tZrq1LWw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@transloadit/prettier-bytes": "0.0.9", - "@uppy/store-default": "^3.2.0", - "@uppy/utils": "^5.7.0", - "lodash": "^4.17.21", - "mime-match": "^1.0.2", - "namespace-emitter": "^2.0.1", - "nanoid": "^4.0.0", - "preact": "^10.5.13" - } - }, "services/web/node_modules/@uppy/dashboard": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/@uppy/dashboard/-/dashboard-3.7.1.tgz", @@ -45951,17 +45564,6 @@ } } }, - "services/web/node_modules/@uppy/utils": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@uppy/utils/-/utils-5.7.0.tgz", - "integrity": "sha512-AJj7gAx5YfMgyevwOxVdIP2h4Nw/O6h57wKA6gj+Lce6tMORcqzGt4yQiKBsrBI0bPyFWCbzA3vX5t0//1JCBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "lodash": "^4.17.21", - "preact": "^10.5.13" - } - }, "services/web/node_modules/@uppy/xhr-upload": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/@uppy/xhr-upload/-/xhr-upload-3.6.0.tgz", @@ -46125,18 +45727,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "services/web/node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, "services/web/node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -46250,31 +45840,6 @@ "node": ">=6" } }, - "services/web/node_modules/duplexify": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.2.tgz", - "integrity": "sha512-fz3OjcNCHmRP12MJoZMPglx8m4rrFP8rovnk4vT8Fs+aonZoCwGg10dSsQsfP/E62eZcPTMSMP6686fu9Qlqtw==", - "dependencies": { - "end-of-stream": "^1.4.1", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1", - "stream-shift": "^1.0.0" - } - }, - "services/web/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "services/web/node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -46314,6 +45879,19 @@ "node": ">=18.11.0" } }, + "services/web/node_modules/globals": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.2.0.tgz", + "integrity": "sha512-O+7l9tPdHCU320IigZZPj5zmRCFG9xHmx9cU8FqU2Rp+JN714seHV+2S9+JslCpY4gJwU2vOGox0wzgae/MCEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "services/web/node_modules/google-auth-library": { "version": "8.7.0", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-8.7.0.tgz", @@ -46619,26 +46197,6 @@ "stack-trace": "0.0.10" } }, - "services/web/node_modules/schema-utils": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz", - "integrity": "sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "services/web/node_modules/semver": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", @@ -46781,41 +46339,6 @@ "node": ">= 6" } }, - "services/web/node_modules/terser-webpack-plugin": { - "version": "5.3.11", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.11.tgz", - "integrity": "sha512-RVCsMfuD0+cTt3EwX8hSl2Ks56EbFHWmhluwcqoPKtBnfjiT6olaq7PRIRfhyU8nnC2MrnDrBLfrD/RGE+cVXQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "jest-worker": "^27.4.5", - "schema-utils": "^4.3.0", - "serialize-javascript": "^6.0.2", - "terser": "^5.31.1" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.1.0" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "uglify-js": { - "optional": true - } - } - }, "services/web/node_modules/tinyrainbow": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", diff --git a/package.json b/package.json index a51bbcd743..388b750c3d 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,8 @@ "@types/chai": "^4.3.0", "@types/chai-as-promised": "^7.1.8", "@types/mocha": "^10.0.6", - "@typescript-eslint/eslint-plugin": "^8.0.0", - "@typescript-eslint/parser": "^8.0.0", + "@typescript-eslint/eslint-plugin": "^8.30.1", + "@typescript-eslint/parser": "^8.30.1", "eslint": "^8.15.0", "eslint-config-prettier": "^8.5.0", "eslint-config-standard": "^17.0.0", @@ -18,28 +18,21 @@ "eslint-plugin-cypress": "^2.15.1", "eslint-plugin-import": "^2.26.0", "eslint-plugin-mocha": "^10.1.0", - "eslint-plugin-node": "^11.1.0", + "eslint-plugin-n": "^15.7.0", "eslint-plugin-prettier": "^4.0.0", "eslint-plugin-promise": "^6.0.0", "eslint-plugin-unicorn": "^56.0.0", - "prettier": "3.3.3", - "typescript": "^5.5.4" + "prettier": "3.6.2", + "typescript": "^5.8.3" + }, + "engines": { + "npm": "11.4.2" }, "overrides": { - "cross-env": { - "cross-spawn": "^7.0.6" - }, - "fetch-mock": { - "path-to-regexp": "3.3.0" - }, - "google-gax": { - "protobufjs": "^7.2.5" - }, - "swagger-tools": { - "body-parser": "1.20.3", - "multer": "2.0.1", + "swagger-tools@0.10.4": { "path-to-regexp": "3.3.0", - "qs": "6.13.0" + "body-parser": "1.20.3", + "multer": "2.0.1" } }, "scripts": { diff --git a/server-ce/Dockerfile b/server-ce/Dockerfile index 0c33505550..5f32eb67cb 100644 --- a/server-ce/Dockerfile +++ b/server-ce/Dockerfile @@ -115,9 +115,3 @@ ENV LOG_LEVEL="info" EXPOSE 80 ENTRYPOINT ["/sbin/my_init"] - -# Store the revision -# ------------------ -# This should be the last step to optimize docker image caching. -ARG MONOREPO_REVISION -RUN echo "monorepo-server-ce,$MONOREPO_REVISION" > /var/www/revisions.txt diff --git a/server-ce/Makefile b/server-ce/Makefile index eb6ea772f1..853a99d05e 100644 --- a/server-ce/Makefile +++ b/server-ce/Makefile @@ -33,7 +33,7 @@ build-community: --build-arg BUILDKIT_INLINE_CACHE=1 \ --progress=plain \ --build-arg OVERLEAF_BASE_TAG \ - --build-arg MONOREPO_REVISION \ + --label "com.overleaf.ce.revision=$(MONOREPO_REVISION)" \ --cache-from $(OVERLEAF_LATEST) \ --cache-from $(OVERLEAF_BRANCH) \ --file Dockerfile \ diff --git a/server-ce/config/settings.js b/server-ce/config/settings.js index a7e8219858..47d34fd870 100644 --- a/server-ce/config/settings.js +++ b/server-ce/config/settings.js @@ -184,7 +184,10 @@ const settings = { siteUrl: (siteUrl = process.env.OVERLEAF_SITE_URL || 'http://localhost'), // Status page URL as displayed on the maintenance/500 pages. - statusPageUrl: process.env.OVERLEAF_STATUS_PAGE_URL, + statusPageUrl: process.env.OVERLEAF_STATUS_PAGE_URL ? + // Add https:// protocol prefix if not set (Allow plain-text http:// for Server Pro/CE). + (process.env.OVERLEAF_STATUS_PAGE_URL.startsWith('http://') || process.env.OVERLEAF_STATUS_PAGE_URL.startsWith('https://')) ? process.env.OVERLEAF_STATUS_PAGE_URL : `https://${process.env.OVERLEAF_STATUS_PAGE_URL}` + : undefined, // The name this is used to describe your Overleaf Community Edition Installation appName: process.env.OVERLEAF_APP_NAME || 'Overleaf Community Edition', diff --git a/server-ce/cron/deactivate-projects.sh b/server-ce/cron/deactivate-projects.sh index fab0fbfbf6..a391f99a5b 100755 --- a/server-ce/cron/deactivate-projects.sh +++ b/server-ce/cron/deactivate-projects.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -set -eux +set -eu echo "-------------------------" echo "Deactivating old projects" diff --git a/server-ce/cron/delete-projects.sh b/server-ce/cron/delete-projects.sh index e1ea5ac5e6..7cd4577171 100755 --- a/server-ce/cron/delete-projects.sh +++ b/server-ce/cron/delete-projects.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -set -eux +set -eu echo "-------------------------" echo "Expiring deleted projects" diff --git a/server-ce/cron/delete-users.sh b/server-ce/cron/delete-users.sh index fe97bffeea..30872ac556 100755 --- a/server-ce/cron/delete-users.sh +++ b/server-ce/cron/delete-users.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -set -eux +set -eu echo "----------------------" echo "Expiring deleted users" diff --git a/server-ce/cron/project-history-flush-all.sh b/server-ce/cron/project-history-flush-all.sh index d8bbb184aa..8fe9eea5fc 100755 --- a/server-ce/cron/project-history-flush-all.sh +++ b/server-ce/cron/project-history-flush-all.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -set -eux +set -eu echo "---------------------------------" echo "Flush all project-history changes" diff --git a/server-ce/cron/project-history-periodic-flush.sh b/server-ce/cron/project-history-periodic-flush.sh index 76feae410e..1b8efff6cc 100755 --- a/server-ce/cron/project-history-periodic-flush.sh +++ b/server-ce/cron/project-history-periodic-flush.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -set -eux +set -eu echo "--------------------------" echo "Flush project-history queue" diff --git a/server-ce/cron/project-history-retry-hard.sh b/server-ce/cron/project-history-retry-hard.sh index 651a6615f2..df9b4703a5 100755 --- a/server-ce/cron/project-history-retry-hard.sh +++ b/server-ce/cron/project-history-retry-hard.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -set -eux +set -eu echo "-----------------------------------" echo "Retry project-history errors (hard)" diff --git a/server-ce/cron/project-history-retry-soft.sh b/server-ce/cron/project-history-retry-soft.sh index 70c597021b..cbb6e714ca 100755 --- a/server-ce/cron/project-history-retry-soft.sh +++ b/server-ce/cron/project-history-retry-soft.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -set -eux +set -eu echo "-----------------------------------" echo "Retry project-history errors (soft)" diff --git a/server-ce/hotfix/5.5.2/Dockerfile b/server-ce/hotfix/5.5.2/Dockerfile new file mode 100644 index 0000000000..13f82e81a4 --- /dev/null +++ b/server-ce/hotfix/5.5.2/Dockerfile @@ -0,0 +1,27 @@ +FROM sharelatex/sharelatex:5.5.1 + +# https://github.com/overleaf/internal/pull/25944 +# Removed changes to services/web/frontend/js/features/ide-redesign/components/rail.tsx due to incompatibility with 5.5.1 +COPY pr_25944.patch . +RUN patch -p1 < pr_25944.patch && rm pr_25944.patch + +# https://github.com/overleaf/internal/pull/26637 +# Removed changes to server-ce/test/create-and-compile-project.spec.ts and server-ce/test/helpers/compile.ts due to incompatibility with 5.5.1 +COPY pr_26637.patch . +RUN patch -p1 < pr_26637.patch && rm pr_26637.patch + +# https://github.com/overleaf/internal/pull/26783 +COPY pr_26783.patch . +RUN patch -p1 < pr_26783.patch && rm pr_26783.patch + +# https://github.com/overleaf/internal/pull/26697 +COPY pr_26697.patch . +RUN patch -p1 < pr_26697.patch && rm pr_26697.patch + +# Apply security updates to base image +RUN apt update && apt install -y linux-libc-dev \ + && unattended-upgrade --verbose --no-minimal-upgrade-steps \ + && rm -rf /var/lib/apt/lists/* + +# Recompile frontend assets +RUN node genScript compile | bash diff --git a/server-ce/hotfix/5.5.2/pr_25944.patch b/server-ce/hotfix/5.5.2/pr_25944.patch new file mode 100644 index 0000000000..e3b9f54246 --- /dev/null +++ b/server-ce/hotfix/5.5.2/pr_25944.patch @@ -0,0 +1,219 @@ +diff --git a/services/web/frontend/js/features/review-panel-new/context/review-panel-providers.tsx b/services/web/frontend/js/features/review-panel-new/context/review-panel-providers.tsx +index 20e157dfee9..ad943772d0d 100644 +--- a/services/web/frontend/js/features/review-panel-new/context/review-panel-providers.tsx ++++ b/services/web/frontend/js/features/review-panel-new/context/review-panel-providers.tsx +@@ -4,10 +4,16 @@ import { ChangesUsersProvider } from './changes-users-context' + import { TrackChangesStateProvider } from './track-changes-state-context' + import { ThreadsProvider } from './threads-context' + import { ReviewPanelViewProvider } from './review-panel-view-context' ++import { useProjectContext } from '@/shared/context/project-context' + + export const ReviewPanelProviders: FC = ({ + children, + }) => { ++ const { features } = useProjectContext() ++ if (!features.trackChangesVisible) { ++ return children ++ } ++ + return ( + + +diff --git a/services/web/frontend/js/features/share-project-modal/components/add-collaborators.tsx b/services/web/frontend/js/features/share-project-modal/components/add-collaborators.tsx +index 8606fb11fad..e80fb037116 100644 +--- a/services/web/frontend/js/features/share-project-modal/components/add-collaborators.tsx ++++ b/services/web/frontend/js/features/share-project-modal/components/add-collaborators.tsx +@@ -176,24 +176,34 @@ export default function AddCollaborators({ readOnly }: { readOnly?: boolean }) { + ]) + + const privilegeOptions = useMemo(() => { +- return [ ++ const options: { ++ key: string ++ label: string ++ description?: string | null ++ }[] = [ + { + key: 'readAndWrite', + label: t('editor'), + }, +- { ++ ] ++ ++ if (features.trackChangesVisible) { ++ options.push({ + key: 'review', + label: t('reviewer'), + description: !features.trackChanges + ? t('comment_only_upgrade_for_track_changes') + : null, +- }, +- { +- key: 'readOnly', +- label: t('viewer'), +- }, +- ] +- }, [features.trackChanges, t]) ++ }) ++ } ++ ++ options.push({ ++ key: 'readOnly', ++ label: t('viewer'), ++ }) ++ ++ return options ++ }, [features.trackChanges, features.trackChangesVisible, t]) + + return ( + +diff --git a/services/web/frontend/js/features/share-project-modal/components/edit-member.tsx b/services/web/frontend/js/features/share-project-modal/components/edit-member.tsx +index 6d806968b12..9f24cddc4ad 100644 +--- a/services/web/frontend/js/features/share-project-modal/components/edit-member.tsx ++++ b/services/web/frontend/js/features/share-project-modal/components/edit-member.tsx +@@ -244,14 +244,22 @@ function SelectPrivilege({ + const { features } = useProjectContext() + + const privileges = useMemo( +- (): Privilege[] => [ +- { key: 'owner', label: t('make_owner') }, +- { key: 'readAndWrite', label: t('editor') }, +- { key: 'review', label: t('reviewer') }, +- { key: 'readOnly', label: t('viewer') }, +- { key: 'removeAccess', label: t('remove_access') }, +- ], +- [t] ++ (): Privilege[] => ++ features.trackChangesVisible ++ ? [ ++ { key: 'owner', label: t('make_owner') }, ++ { key: 'readAndWrite', label: t('editor') }, ++ { key: 'review', label: t('reviewer') }, ++ { key: 'readOnly', label: t('viewer') }, ++ { key: 'removeAccess', label: t('remove_access') }, ++ ] ++ : [ ++ { key: 'owner', label: t('make_owner') }, ++ { key: 'readAndWrite', label: t('editor') }, ++ { key: 'readOnly', label: t('viewer') }, ++ { key: 'removeAccess', label: t('remove_access') }, ++ ], ++ [features.trackChangesVisible, t] + ) + + const downgradedPseudoPrivilege: Privilege = { +diff --git a/services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx b/services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx +index c1808cbb301..4bdfe2682c8 100644 +--- a/services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx ++++ b/services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx +@@ -18,6 +18,7 @@ import { + } from './codemirror-context' + import MathPreviewTooltip from './math-preview-tooltip' + import { useToolbarMenuBarEditorCommands } from '@/features/ide-redesign/hooks/use-toolbar-menu-editor-commands' ++import { useProjectContext } from '@/shared/context/project-context' + + // TODO: remove this when definitely no longer used + export * from './codemirror-context' +@@ -67,6 +68,7 @@ function CodeMirrorEditor() { + + function CodeMirrorEditorComponents() { + useToolbarMenuBarEditorCommands() ++ const { features } = useProjectContext() + + return ( + +@@ -83,8 +85,8 @@ function CodeMirrorEditorComponents() { + + + +- +- ++ {features.trackChangesVisible && } ++ {features.trackChangesVisible && } + + {sourceEditorComponents.map( + ({ import: { default: Component }, path }) => ( +diff --git a/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx b/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx +index e70663683fc..c5d9f3d3e47 100644 +--- a/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx ++++ b/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx +@@ -14,6 +14,7 @@ import { LegacyTableDropdown } from './table-inserter-dropdown-legacy' + import { withinFormattingCommand } from '@/features/source-editor/utils/tree-operations/formatting' + import { isSplitTestEnabled } from '@/utils/splitTestUtils' + import { isMac } from '@/shared/utils/os' ++import { useProjectContext } from '@/shared/context/project-context' + + export const ToolbarItems: FC<{ + state: EditorState +@@ -31,6 +32,7 @@ export const ToolbarItems: FC<{ + const { t } = useTranslation() + const { toggleSymbolPalette, showSymbolPalette, writefullInstance } = + useEditorContext() ++ const { features } = useProjectContext() + const isActive = withinFormattingCommand(state) + + const symbolPaletteAvailable = getMeta('ol-symbolPaletteAvailable') +@@ -127,13 +129,15 @@ export const ToolbarItems: FC<{ + command={commands.wrapInHref} + icon="add_link" + /> +- ++ {features.trackChangesVisible && ( ++ ++ )} + ', function () { + removeChangeIds, + }, + }, ++ projectFeatures: { trackChangesVisible: true }, + }) + + cy.wrap(scope).as('scope') +@@ -626,7 +627,7 @@ describe(' for free users', function () { + function mountEditor(ownerId = USER_ID) { + const scope = mockScope(undefined, { + permissions: { write: true, trackedWrite: false, comment: true }, +- projectFeatures: { trackChanges: false }, ++ projectFeatures: { trackChanges: false, trackChangesVisible: true }, + projectOwner: { + _id: ownerId, + }, +diff --git a/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.jsx b/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.jsx +index b86207fb0f7..dfce8134d1c 100644 +--- a/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.jsx ++++ b/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.jsx +@@ -694,6 +694,7 @@ describe('', function () { + features: { + collaborators: 0, + compileGroup: 'standard', ++ trackChangesVisible: true, + }, + }, + }, +@@ -723,6 +724,7 @@ describe('', function () { + ...project, + features: { + collaborators: 1, ++ trackChangesVisible: true, + }, + members: [ + { diff --git a/server-ce/hotfix/5.5.2/pr_26637.patch b/server-ce/hotfix/5.5.2/pr_26637.patch new file mode 100644 index 0000000000..f2183723bf --- /dev/null +++ b/server-ce/hotfix/5.5.2/pr_26637.patch @@ -0,0 +1,86 @@ +diff --git a/services/clsi/app/js/LocalCommandRunner.js b/services/clsi/app/js/LocalCommandRunner.js +index ce274733585..aa62825443c 100644 +--- a/services/clsi/app/js/LocalCommandRunner.js ++++ b/services/clsi/app/js/LocalCommandRunner.js +@@ -54,6 +54,7 @@ module.exports = CommandRunner = { + cwd: directory, + env, + stdio: ['pipe', 'pipe', 'ignore'], ++ detached: true, + }) + + let stdout = '' +diff --git a/services/clsi/test/acceptance/js/StopCompile.js b/services/clsi/test/acceptance/js/StopCompile.js +new file mode 100644 +index 00000000000..103a70f37d7 +--- /dev/null ++++ b/services/clsi/test/acceptance/js/StopCompile.js +@@ -0,0 +1,47 @@ ++const Client = require('./helpers/Client') ++const ClsiApp = require('./helpers/ClsiApp') ++const { expect } = require('chai') ++ ++describe('Stop compile', function () { ++ before(function (done) { ++ this.request = { ++ options: { ++ timeout: 100, ++ }, // seconds ++ resources: [ ++ { ++ path: 'main.tex', ++ content: `\ ++\\documentclass{article} ++\\begin{document} ++\\def\\x{Hello!\\par\\x} ++\\x ++\\end{document}\ ++`, ++ }, ++ ], ++ } ++ this.project_id = Client.randomId() ++ ClsiApp.ensureRunning(() => { ++ // start the compile in the background ++ Client.compile(this.project_id, this.request, (error, res, body) => { ++ this.compileResult = { error, res, body } ++ }) ++ // wait for 1 second before stopping the compile ++ setTimeout(() => { ++ Client.stopCompile(this.project_id, (error, res, body) => { ++ this.stopResult = { error, res, body } ++ setTimeout(done, 1000) // allow time for the compile request to terminate ++ }) ++ }, 1000) ++ }) ++ }) ++ ++ it('should force a compile response with an error status', function () { ++ expect(this.stopResult.error).to.be.null ++ expect(this.stopResult.res.statusCode).to.equal(204) ++ expect(this.compileResult.res.statusCode).to.equal(200) ++ expect(this.compileResult.body.compile.status).to.equal('terminated') ++ expect(this.compileResult.body.compile.error).to.equal('terminated') ++ }) ++}) +diff --git a/services/clsi/test/acceptance/js/helpers/Client.js b/services/clsi/test/acceptance/js/helpers/Client.js +index a0bdce734f3..49bf7390c6f 100644 +--- a/services/clsi/test/acceptance/js/helpers/Client.js ++++ b/services/clsi/test/acceptance/js/helpers/Client.js +@@ -42,6 +42,16 @@ module.exports = Client = { + ) + }, + ++ stopCompile(projectId, callback) { ++ if (callback == null) { ++ callback = function () {} ++ } ++ return request.post( ++ { url: `${this.host}/project/${projectId}/compile/stop` }, ++ callback ++ ) ++ }, ++ + clearCache(projectId, callback) { + if (callback == null) { + callback = function () {} diff --git a/server-ce/hotfix/5.5.2/pr_26697.patch b/server-ce/hotfix/5.5.2/pr_26697.patch new file mode 100644 index 0000000000..a6dd006d0a --- /dev/null +++ b/server-ce/hotfix/5.5.2/pr_26697.patch @@ -0,0 +1,172 @@ +diff --git a/services/web/frontend/js/features/project-list/components/project-list-ds-nav.tsx b/services/web/frontend/js/features/project-list/components/project-list-ds-nav.tsx +index 8f3b3a8e5d0..f8c8014e1c0 100644 +--- a/services/web/frontend/js/features/project-list/components/project-list-ds-nav.tsx ++++ b/services/web/frontend/js/features/project-list/components/project-list-ds-nav.tsx +@@ -55,7 +55,11 @@ export function ProjectListDsNav() { + + return ( +
+- ++ +
+ +
+diff --git a/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/default-navbar.tsx b/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/default-navbar.tsx +index 2480b7f061f..8e5429dbde6 100644 +--- a/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/default-navbar.tsx ++++ b/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/default-navbar.tsx +@@ -1,4 +1,4 @@ +-import { useState } from 'react' ++import React, { useState } from 'react' + import { sendMB } from '@/infrastructure/event-tracking' + import { useTranslation } from 'react-i18next' + import { Button, Container, Nav, Navbar } from 'react-bootstrap' +@@ -13,9 +13,15 @@ import MaterialIcon from '@/shared/components/material-icon' + import { useContactUsModal } from '@/shared/hooks/use-contact-us-modal' + import { UserProvider } from '@/shared/context/user-context' + import { X } from '@phosphor-icons/react' ++import overleafWhiteLogo from '@/shared/svgs/overleaf-white.svg' ++import overleafBlackLogo from '@/shared/svgs/overleaf-black.svg' ++import type { CSSPropertiesWithVariables } from '../../../../../../../types/css-properties-with-variables' + +-function DefaultNavbar(props: DefaultNavbarMetadata) { ++function DefaultNavbar( ++ props: DefaultNavbarMetadata & { overleafLogo?: string } ++) { + const { ++ overleafLogo, + customLogo, + title, + canDisplayAdminMenu, +@@ -49,10 +55,20 @@ function DefaultNavbar(props: DefaultNavbarMetadata) { + className="navbar-default navbar-main" + expand="lg" + onToggle={expanded => setExpanded(expanded)} ++ style={ ++ { ++ '--navbar-brand-image-default-url': `url("${overleafWhiteLogo}")`, ++ '--navbar-brand-image-redesign-url': `url("${overleafBlackLogo}")`, ++ } as CSSPropertiesWithVariables ++ } + > + +
+- ++ + {enableUpgradeButton ? ( + ) } else { @@ -63,7 +65,7 @@ export default function LeftMenuButton({ className="left-menu-button" > - {children} + {children} ) } diff --git a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-compiler.tsx b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-compiler.tsx index 8d7076ebd4..2eab7f25b5 100644 --- a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-compiler.tsx +++ b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-compiler.tsx @@ -34,6 +34,7 @@ export default function SettingsCompiler() { ]} label={t('compiler')} name="compiler" + translateOptions="no" /> ) } diff --git a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-document.tsx b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-document.tsx index 839bd499eb..8655a63cfc 100644 --- a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-document.tsx +++ b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-document.tsx @@ -43,6 +43,7 @@ export default function SettingsDocument() { options={validDocsOptions} label={t('main_document')} name="rootDocId" + translateOptions="no" /> ) } diff --git a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-editor-theme.tsx b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-editor-theme.tsx index 5f9ad51869..870ce48ca1 100644 --- a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-editor-theme.tsx +++ b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-editor-theme.tsx @@ -40,6 +40,7 @@ export default function SettingsEditorTheme() { options={options} label={t('editor_theme')} name="editorTheme" + translateOptions="no" /> ) } diff --git a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-font-family.tsx b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-font-family.tsx index 5a327093a4..61f85c1e70 100644 --- a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-font-family.tsx +++ b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-font-family.tsx @@ -1,7 +1,6 @@ import { useTranslation } from 'react-i18next' import { useProjectSettingsContext } from '../../context/project-settings-context' import SettingsMenuSelect from './settings-menu-select' -import BetaBadge from '@/shared/components/beta-badge' import { FontFamily } from '@/shared/utils/styles' export default function SettingsFontFamily() { @@ -9,39 +8,26 @@ export default function SettingsFontFamily() { const { fontFamily, setFontFamily } = useProjectSettingsContext() return ( -
- - onChange={setFontFamily} - value={fontFamily} - options={[ - { - value: 'monaco', - label: 'Monaco / Menlo / Consolas', - }, - { - value: 'lucida', - label: 'Lucida / Source Code Pro', - }, - { - value: 'opendyslexicmono', - label: 'OpenDyslexic Mono', - }, - ]} - label={t('font_family')} - name="fontFamily" - /> - -
+ + onChange={setFontFamily} + value={fontFamily} + options={[ + { + value: 'monaco', + label: 'Monaco / Menlo / Consolas', + }, + { + value: 'lucida', + label: 'Lucida / Source Code Pro', + }, + { + value: 'opendyslexicmono', + label: 'OpenDyslexic Mono', + }, + ]} + label={t('font_family')} + name="fontFamily" + translateOptions="no" + /> ) } diff --git a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-image-name.tsx b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-image-name.tsx index f52347814a..a0499fe569 100644 --- a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-image-name.tsx +++ b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-image-name.tsx @@ -37,6 +37,7 @@ export default function SettingsImageName() { options={options} label={t('tex_live_version')} name="imageName" + translateOptions="no" /> ) } diff --git a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-menu-select.tsx b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-menu-select.tsx index 6b1f06ec36..c48486ec1c 100644 --- a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-menu-select.tsx +++ b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-menu-select.tsx @@ -28,6 +28,7 @@ type SettingsMenuSelectProps = { onChange: (val: T) => void value?: T disabled?: boolean + translateOptions?: 'yes' | 'no' } export default function SettingsMenuSelect({ @@ -39,6 +40,7 @@ export default function SettingsMenuSelect({ onChange, value, disabled = false, + translateOptions, }: SettingsMenuSelectProps) { const handleChange: ChangeEventHandler = useCallback( event => { @@ -95,6 +97,7 @@ export default function SettingsMenuSelect({ value={value?.toString()} disabled={disabled} ref={selectRef} + translate={translateOptions} > {options.map(option => (
diff --git a/services/web/frontend/js/features/project-list/components/sidebar/sidebar-ds-nav.tsx b/services/web/frontend/js/features/project-list/components/sidebar/sidebar-ds-nav.tsx index eba033ea76..e548d994ec 100644 --- a/services/web/frontend/js/features/project-list/components/sidebar/sidebar-ds-nav.tsx +++ b/services/web/frontend/js/features/project-list/components/sidebar/sidebar-ds-nav.tsx @@ -33,7 +33,7 @@ function SidebarDsNav() { const sendMB = useSendProjectListMB() const { sessionUser, showSubscriptionLink, items } = getMeta('ol-navbar') const helpItem = items.find( - item => item.text === 'help' + item => item.text === 'help_and_resources' ) as NavbarDropdownItemData const { containerRef, scrolledUp, scrolledDown } = useScrolled() return ( @@ -49,7 +49,11 @@ function SidebarDsNav() { id="new-project-button-sidebar" className={scrolledDown ? 'show-shadow' : undefined} /> -
+
{showAddAffiliationWidget &&
} diff --git a/services/web/frontend/js/features/project-list/components/sidebar/sidebar.tsx b/services/web/frontend/js/features/project-list/components/sidebar/sidebar.tsx deleted file mode 100644 index 55bab83261..0000000000 --- a/services/web/frontend/js/features/project-list/components/sidebar/sidebar.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import NewProjectButton from '../new-project-button' -import SidebarFilters from './sidebar-filters' -import AddAffiliation, { useAddAffiliation } from '../add-affiliation' -import SurveyWidget from '../survey-widget' -import { usePersistedResize } from '../../../../shared/hooks/use-resize' - -function Sidebar() { - const { show: showAddAffiliationWidget } = useAddAffiliation() - const { mousePos, getHandleProps, getTargetProps } = usePersistedResize({ - name: 'project-sidebar', - }) - - return ( -
-
- -
- -
-
-
-
- ) -} - -export default Sidebar diff --git a/services/web/frontend/js/features/project-list/components/survey-widget-ds-nav.tsx b/services/web/frontend/js/features/project-list/components/survey-widget-ds-nav.tsx index 5fcc0f7e3d..9264b3288d 100644 --- a/services/web/frontend/js/features/project-list/components/survey-widget-ds-nav.tsx +++ b/services/web/frontend/js/features/project-list/components/survey-widget-ds-nav.tsx @@ -26,8 +26,8 @@ export function SurveyWidgetDsNav() {
-

{survey.preText}

-

{survey.linkText}

+

{survey.title}

+

{survey.text}

- {t('take_survey')} + {survey.cta || t('take_survey')}
{ - setDismissedSurvey(true) - }, [setDismissedSurvey]) - - if (!survey?.name || dismissedSurvey) { - return null - } - - return ( -
-
-
-
- {survey.preText}  - - {survey.linkText} - -
-
- dismissSurvey()} /> -
-
-
-
- ) -} diff --git a/services/web/frontend/js/features/project-list/components/welcome-message-new/welcome-message-link.tsx b/services/web/frontend/js/features/project-list/components/welcome-message-new/welcome-message-link.tsx index b5ec5c6cd2..179fb70b83 100644 --- a/services/web/frontend/js/features/project-list/components/welcome-message-new/welcome-message-link.tsx +++ b/services/web/frontend/js/features/project-list/components/welcome-message-new/welcome-message-link.tsx @@ -23,12 +23,7 @@ export default function WelcomeMessageLink({ rel="noopener" >

{title}

- +
) diff --git a/services/web/frontend/js/features/review-panel-new/components/review-mode-switcher.tsx b/services/web/frontend/js/features/review-panel-new/components/review-mode-switcher.tsx index f2bb2763bd..b95b1a11b4 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-mode-switcher.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-mode-switcher.tsx @@ -17,11 +17,13 @@ import { usePermissionsContext } from '@/features/ide-react/context/permissions- import usePersistedState from '@/shared/hooks/use-persisted-state' import { sendMB } from '@/infrastructure/event-tracking' import { useEditorContext } from '@/shared/context/editor-context' +import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context' import { useProjectContext } from '@/shared/context/project-context' import UpgradeTrackChangesModal from './upgrade-track-changes-modal' import { ReviewModePromo } from '@/features/review-panel-new/components/review-mode-promo' import useTutorial from '@/shared/hooks/promotions/use-tutorial' import { useLayoutContext } from '@/shared/context/layout-context' +import { useCodeMirrorViewContext } from '@/features/source-editor/components/codemirror-context' type Mode = 'view' | 'review' | 'edit' @@ -30,8 +32,9 @@ const useCurrentMode = (): Mode => { const user = useUserContext() const trackChangesForCurrentUser = trackChanges?.onForEveryone || - (user && user.id && trackChanges?.onForMembers[user.id]) - const { permissionsLevel } = useEditorContext() + (user?.id && trackChanges?.onForMembers[user.id]) || + (!user?.id && trackChanges?.onForGuests) + const { permissionsLevel } = useIdeReactContext() if (permissionsLevel === 'readOnly') { return 'view' @@ -46,14 +49,16 @@ const useCurrentMode = (): Mode => { function ReviewModeSwitcher() { const { t } = useTranslation() - const { saveTrackChangesForCurrentUser } = + const user = useUserContext() + const { saveTrackChangesForCurrentUser, saveTrackChanges } = useTrackChangesStateActionsContext() const mode = useCurrentMode() - const { permissionsLevel } = useEditorContext() + const { permissionsLevel } = useIdeReactContext() const { write, trackedWrite } = usePermissionsContext() - const project = useProjectContext() + const { features } = useProjectContext() const [showUpgradeModal, setShowUpgradeModal] = useState(false) const showViewOption = permissionsLevel === 'readOnly' + const view = useCodeMirrorViewContext() return (
@@ -67,6 +72,7 @@ function ReviewModeSwitcher() { disabled={!write} onClick={() => { if (mode === 'edit') { + view.focus() return } sendMB('editing-mode-change', { @@ -74,7 +80,12 @@ function ReviewModeSwitcher() { previousMode: mode, newMode: 'edit', }) - saveTrackChangesForCurrentUser(false) + if (user?.id) { + saveTrackChangesForCurrentUser(false) + } else { + saveTrackChanges({ on_for_guests: false }) + } + view.focus() }} description={t('edit_content_directly')} leadingIcon="edit" @@ -86,9 +97,10 @@ function ReviewModeSwitcher() { disabled={permissionsLevel === 'readOnly'} onClick={() => { if (mode === 'review') { + view.focus() return } - if (!project.features.trackChanges) { + if (!features.trackChanges) { setShowUpgradeModal(true) } else { sendMB('editing-mode-change', { @@ -96,7 +108,12 @@ function ReviewModeSwitcher() { previousMode: mode, newMode: 'review', }) - saveTrackChangesForCurrentUser(true) + if (user?.id) { + saveTrackChangesForCurrentUser(true) + } else { + saveTrackChanges({ on_for_guests: true }) + } + view.focus() } }} description={ @@ -195,7 +212,7 @@ const ModeSwitcherToggleButtonContent = forwardRef< }) const user = useUserContext() - const project = useProjectContext() + const { features } = useProjectContext() const { reviewPanelOpen } = useLayoutContext() const { inactiveTutorials } = useEditorContext() @@ -205,7 +222,7 @@ const ModeSwitcherToggleButtonContent = forwardRef< const canShowReviewModePromo = reviewPanelOpen && currentMode !== 'review' && - project.features.trackChanges && + features.trackChanges && user.signUpDate && user.signUpDate < '2025-03-15' && !hasCompletedReviewModeTutorial diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-add-comment.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-add-comment.tsx index 87f9934395..f3e8b88a47 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-panel-add-comment.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-add-comment.tsx @@ -102,19 +102,25 @@ export const ReviewPanelAddComment = memo<{ } }, []) + const observerRef = useRef(null) + const handleElement = useCallback( (element: HTMLElement | null) => { if (element) { element.dispatchEvent(new Event('review-panel:position')) - const observer = new MutationObserver(observerCallback) + observerRef.current = new MutationObserver(observerCallback) const entryWrapper = element.closest('.review-panel-entry') if (entryWrapper) { - observer.observe(entryWrapper, { + observerRef.current.observe(entryWrapper, { attributes: true, attributeFilter: ['style'], }) - return () => observer.disconnect() + } + } else { + // [TODO React 19] return a cleanup function instead of using null element + if (observerRef.current) { + observerRef.current.disconnect() } } }, diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-entry-user.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-entry-user.tsx index 932813faef..221e297c4b 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-panel-entry-user.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-entry-user.tsx @@ -3,16 +3,18 @@ import { buildName } from '../utils/build-name' import { ReviewPanelUser } from '../../../../../types/review-panel/review-panel' import { ChangesUser } from '../context/changes-users-context' import { getBackgroundColorForUserId } from '@/shared/utils/colors' +import { useTranslation } from 'react-i18next' const ReviewPanelEntryUser = ({ user, }: { user?: ReviewPanelUser | ChangesUser }) => { - const userName = buildName(user) + const { t } = useTranslation() + const userName = user ? buildName(user) : t('deleted_user') return ( -
+
(function ExpandableContent({ content, className, @@ -18,6 +19,7 @@ export const ExpandableContent = memo<{ newLineCharsLimit = 3, checkNewLines = true, inline = false, + translate, }) { const { t } = useTranslation() const contentRef = useRef(null) @@ -50,6 +52,7 @@ export const ExpandableContent = memo<{
{isExpanded ? content : content.slice(0, limit)} {isOverflowing && !isExpanded && '...'} diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-message.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-message.tsx index ae3a66f729..41c8a8d5a2 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-panel-message.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-message.tsx @@ -40,7 +40,7 @@ export const ReviewPanelMessage: FC<{ const user = useUserContext() const permissions = usePermissionsContext() - const isCommentAuthor = user.id === message.user.id + const isCommentAuthor = Boolean(message.user && user.id === message.user.id) const canEdit = isCommentAuthor && permissions.comment const canResolve = permissions.resolveAllComments || @@ -135,6 +135,7 @@ export const ReviewPanelMessage: FC<{ contentLimit={100} checkNewLines content={message.content} + translate="no" /> )} diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-resolved-thread.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-resolved-thread.tsx index 53ac6d2192..76a12cd1c3 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-panel-resolved-thread.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-resolved-thread.tsx @@ -73,7 +73,10 @@ export const ReviewPanelResolvedThread: FC<{ i18nKey="from_filename" components={[ // eslint-disable-next-line react/jsx-key - , + , ]} values={{ filename: docName }} shouldUnescape @@ -121,6 +124,7 @@ export const ReviewPanelResolvedThread: FC<{ className="review-panel-resolved-comment-quoted-text-quote" content={comment?.op.c} checkNewLines + translate="no" />
diff --git a/services/web/frontend/js/features/review-panel-new/components/review-tooltip-menu.tsx b/services/web/frontend/js/features/review-panel-new/components/review-tooltip-menu.tsx index 85fe5830f6..f26542ebe9 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-tooltip-menu.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-tooltip-menu.tsx @@ -31,7 +31,7 @@ import { isCursorNearViewportEdge } from '@/features/source-editor/utils/is-curs import OLTooltip from '@/features/ui/components/ol/ol-tooltip' import { useModalsContext } from '@/features/ide-react/context/modals-context' import { numberOfChangesInSelection } from '../utils/changes-in-selection' -import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context' +import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context' import classNames from 'classnames' import useEventListener from '@/shared/hooks/use-event-listener' import useReviewPanelLayout from '../hooks/use-review-panel-layout' @@ -104,7 +104,7 @@ const ReviewTooltipMenuContent: FC<{ onAddComment: () => void }> = ({ const ranges = useRangesContext() const { acceptChanges, rejectChanges } = useRangesActionsContext() const { showGenericConfirmModal } = useModalsContext() - const { wantTrackChanges } = useEditorManagerContext() + const { wantTrackChanges } = useEditorPropertiesContext() const [tooltipStyle, setTooltipStyle] = useState() const [visible, setVisible] = useState(false) diff --git a/services/web/frontend/js/features/review-panel-new/components/upgrade-track-changes-modal.tsx b/services/web/frontend/js/features/review-panel-new/components/upgrade-track-changes-modal.tsx index 4948a00ce3..4611060438 100644 --- a/services/web/frontend/js/features/review-panel-new/components/upgrade-track-changes-modal.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/upgrade-track-changes-modal.tsx @@ -26,7 +26,7 @@ function UpgradeTrackChangesModal({ setShow, }: UpgradeTrackChangesModalProps) { const { t } = useTranslation() - const project = useProjectContext() + const { project } = useProjectContext() const user = useUserContext() return ( @@ -62,9 +62,9 @@ function UpgradeTrackChangesModal({ - {project.owner && ( + {Boolean(project?.owner) && (
- {project.owner._id === user.id ? ( + {project?.owner._id === user.id ? ( user.allowedFreeTrial ? ( ( export const ChangesUsersProvider: FC = ({ children, }) => { - const { _id: projectId, members, owner } = useProjectContext() + const { projectId, project } = useProjectContext() + const { members, owner } = project || {} const { isRestrictedTokenMember } = useEditorContext() const [changesUsers, setChangesUsers] = useState() @@ -49,6 +50,9 @@ export const ChangesUsersProvider: FC = ({ // add the project owner and members to the changes users data const value = useMemo(() => { + if (!owner || !members) { + return + } const value: ChangesUsers = new Map(changesUsers) value.set(owner._id, { ...owner, id: owner._id }) for (const member of members) { diff --git a/services/web/frontend/js/features/review-panel-new/context/ranges-context.tsx b/services/web/frontend/js/features/review-panel-new/context/ranges-context.tsx index 7066a78bb3..f5e9c94c09 100644 --- a/services/web/frontend/js/features/review-panel-new/context/ranges-context.tsx +++ b/services/web/frontend/js/features/review-panel-new/context/ranges-context.tsx @@ -21,7 +21,7 @@ import { useIdeReactContext } from '@/features/ide-react/context/ide-react-conte import { useConnectionContext } from '@/features/ide-react/context/connection-context' import useSocketListener from '@/features/ide-react/hooks/use-socket-listener' import { throttle } from 'lodash' -import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context' +import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context' export type Ranges = { docId: string @@ -81,7 +81,7 @@ const RangesActionsContext = createContext(undefined) export const RangesProvider: FC = ({ children }) => { const view = useCodeMirrorViewContext() const { projectId } = useIdeReactContext() - const { currentDocument } = useEditorManagerContext() + const { currentDocument } = useEditorOpenDocContext() const { socket } = useConnectionContext() const [ranges, setRanges] = useState(() => buildRanges(currentDocument) diff --git a/services/web/frontend/js/features/review-panel-new/context/review-panel-providers.tsx b/services/web/frontend/js/features/review-panel-new/context/review-panel-providers.tsx index 20e157dfee..ad943772d0 100644 --- a/services/web/frontend/js/features/review-panel-new/context/review-panel-providers.tsx +++ b/services/web/frontend/js/features/review-panel-new/context/review-panel-providers.tsx @@ -4,10 +4,16 @@ import { ChangesUsersProvider } from './changes-users-context' import { TrackChangesStateProvider } from './track-changes-state-context' import { ThreadsProvider } from './threads-context' import { ReviewPanelViewProvider } from './review-panel-view-context' +import { useProjectContext } from '@/shared/context/project-context' export const ReviewPanelProviders: FC = ({ children, }) => { + const { features } = useProjectContext() + if (!features.trackChangesVisible) { + return children + } + return ( diff --git a/services/web/frontend/js/features/review-panel-new/context/threads-context.tsx b/services/web/frontend/js/features/review-panel-new/context/threads-context.tsx index 0a5c737585..6b73073bfc 100644 --- a/services/web/frontend/js/features/review-panel-new/context/threads-context.tsx +++ b/services/web/frontend/js/features/review-panel-new/context/threads-context.tsx @@ -20,7 +20,7 @@ import { UserId } from '../../../../../types/user' import { deleteJSON, getJSON, postJSON } from '@/infrastructure/fetch-json' import RangesTracker from '@overleaf/ranges-tracker' import { CommentOperation } from '../../../../../types/change' -import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context' +import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context' import { useEditorContext } from '@/shared/context/editor-context' import { debugConsole } from '@/utils/debugging' import { captureException } from '@/infrastructure/error-reporter' @@ -49,8 +49,8 @@ const ThreadsActionsContext = createContext( ) export const ThreadsProvider: FC = ({ children }) => { - const { _id: projectId } = useProjectContext() - const { currentDocument } = useEditorManagerContext() + const { projectId } = useProjectContext() + const { currentDocument } = useEditorOpenDocContext() const { isRestrictedTokenMember } = useEditorContext() // const [error, setError] = useState() diff --git a/services/web/frontend/js/features/review-panel-new/context/track-changes-state-context.tsx b/services/web/frontend/js/features/review-panel-new/context/track-changes-state-context.tsx index b621ac8ed8..e68ef1fec1 100644 --- a/services/web/frontend/js/features/review-panel-new/context/track-changes-state-context.tsx +++ b/services/web/frontend/js/features/review-panel-new/context/track-changes-state-context.tsx @@ -11,15 +11,16 @@ import { import useSocketListener from '@/features/ide-react/hooks/use-socket-listener' import { useConnectionContext } from '@/features/ide-react/context/connection-context' import { useProjectContext } from '@/shared/context/project-context' -import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context' +import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context' import { useUserContext } from '@/shared/context/user-context' import { postJSON } from '@/infrastructure/fetch-json' import useEventListener from '@/shared/hooks/use-event-listener' -import { ProjectContextValue } from '@/shared/context/types/project-context' +import { ProjectMetadata } from '@/shared/context/types/project-metadata' import { usePermissionsContext } from '@/features/ide-react/context/permissions-context' export type TrackChangesState = { onForEveryone: boolean + onForGuests: boolean onForMembers: Record } @@ -30,6 +31,7 @@ export const TrackChangesStateContext = createContext< type SaveTrackChangesRequestBody = { on?: boolean on_for?: Record + on_for_guests?: boolean } type TrackChangesStateActions = { @@ -46,34 +48,36 @@ export const TrackChangesStateProvider: FC = ({ }) => { const permissions = usePermissionsContext() const { socket } = useConnectionContext() - const project = useProjectContext() + const { projectId, project, features } = useProjectContext() const user = useUserContext() - const { setWantTrackChanges } = useEditorManagerContext() + const { setWantTrackChanges } = useEditorPropertiesContext() // TODO: update project.trackChangesState instead? const [trackChangesValue, setTrackChangesValue] = useState< - ProjectContextValue['trackChangesState'] - >(project.trackChangesState ?? false) + ProjectMetadata['trackChangesState'] + >(project?.trackChangesState ?? false) useSocketListener(socket, 'toggle-track-changes', setTrackChangesValue) useEffect(() => { setWantTrackChanges( trackChangesValue === true || - (trackChangesValue !== false && !!user.id && trackChangesValue[user.id]) + (trackChangesValue !== false && + trackChangesValue[user.id ?? '__guests__']) ) }, [setWantTrackChanges, trackChangesValue, user.id]) const trackChangesIsObject = trackChangesValue !== true && trackChangesValue !== false const onForEveryone = trackChangesValue === true + const onForGuests = + onForEveryone || + (trackChangesIsObject && trackChangesValue.__guests__ === true) const onForMembers = useMemo(() => { const onForMembers: Record = {} if (trackChangesIsObject) { for (const key of Object.keys(trackChangesValue)) { - // TODO: Remove this check when we have converted - // all projects to the current format. if (key !== '__guests__') { onForMembers[key as UserId] = trackChangesValue[key as UserId] } @@ -84,11 +88,11 @@ export const TrackChangesStateProvider: FC = ({ const saveTrackChanges = useCallback( async (trackChangesBody: SaveTrackChangesRequestBody) => { - postJSON(`/project/${project._id}/track_changes`, { + postJSON(`/project/${projectId}/track_changes`, { body: trackChangesBody, }) }, - [project._id] + [projectId] ) const saveTrackChangesForCurrentUser = useCallback( @@ -118,7 +122,7 @@ export const TrackChangesStateProvider: FC = ({ useCallback(() => { if ( user.id && - project.features.trackChanges && + features.trackChanges && permissions.write && !onForEveryone ) { @@ -135,14 +139,14 @@ export const TrackChangesStateProvider: FC = ({ onForMembers, onForEveryone, permissions.write, - project.features.trackChanges, + features.trackChanges, user.id, ]) ) const value = useMemo( - () => ({ onForEveryone, onForMembers }), - [onForEveryone, onForMembers] + () => ({ onForEveryone, onForGuests, onForMembers }), + [onForEveryone, onForGuests, onForMembers] ) return ( diff --git a/services/web/frontend/js/features/review-panel-new/hooks/use-overview-file-collapsed.ts b/services/web/frontend/js/features/review-panel-new/hooks/use-overview-file-collapsed.ts index fcc3f9a053..f2f12ec664 100644 --- a/services/web/frontend/js/features/review-panel-new/hooks/use-overview-file-collapsed.ts +++ b/services/web/frontend/js/features/review-panel-new/hooks/use-overview-file-collapsed.ts @@ -2,12 +2,41 @@ import { useCallback } from 'react' import { DocId } from '../../../../../types/project-settings' import { useProjectContext } from '../../../shared/context/project-context' import usePersistedState from '../../../shared/hooks/use-persisted-state' +import { debugConsole } from '@/utils/debugging' + +const safeStringify = (value: unknown) => { + try { + return JSON.stringify(value) + } catch (e) { + debugConsole.error('double stringify exception', e) + return '' + } +} + +const safeParse = (value: string) => { + try { + return JSON.parse(value) + } catch (e) { + debugConsole.error('double parse exception', e) + return null + } +} export default function useOverviewFileCollapsed(docId: DocId) { - const { _id: projectId } = useProjectContext() + const { projectId } = useProjectContext() const [collapsedDocs, setCollapsedDocs] = usePersistedState< - Record - >(`docs_collapsed_state:${projectId}`, {}, false, true) + Record, + string + >( + `docs_collapsed_state:${projectId}`, + {}, + { + converter: { + fromPersisted: safeParse, + toPersisted: safeStringify, + }, + } + ) const toggleCollapsed = useCallback(() => { setCollapsedDocs((collapsedDocs: Record) => { diff --git a/services/web/frontend/js/features/review-panel-new/hooks/use-project-ranges.ts b/services/web/frontend/js/features/review-panel-new/hooks/use-project-ranges.ts index 95e854d9ca..23ff05479e 100644 --- a/services/web/frontend/js/features/review-panel-new/hooks/use-project-ranges.ts +++ b/services/web/frontend/js/features/review-panel-new/hooks/use-project-ranges.ts @@ -6,7 +6,7 @@ import useSocketListener from '@/features/ide-react/hooks/use-socket-listener' import { useConnectionContext } from '@/features/ide-react/context/connection-context' export default function useProjectRanges() { - const { _id: projectId } = useProjectContext() + const { projectId } = useProjectContext() const [error, setError] = useState() const [projectRanges, setProjectRanges] = useState>() const [loading, setLoading] = useState(true) diff --git a/services/web/frontend/js/features/review-panel-new/utils/build-name.ts b/services/web/frontend/js/features/review-panel-new/utils/build-name.ts index 9bfb6902c4..bd1fbf1a12 100644 --- a/services/web/frontend/js/features/review-panel-new/utils/build-name.ts +++ b/services/web/frontend/js/features/review-panel-new/utils/build-name.ts @@ -1,15 +1,17 @@ -export const buildName = (user?: { +export const buildName = (user: { first_name?: string last_name?: string email?: string }) => { - const name = [user?.first_name, user?.last_name].filter(Boolean).join(' ') + const name = [user.first_name, user.last_name].filter(Boolean).join(' ') if (name) { return name } - if (user?.email) { + + if (user.email) { return user.email.split('@')[0] } + return 'Unknown' } diff --git a/services/web/frontend/js/features/review-panel-new/utils/has-active-range.ts b/services/web/frontend/js/features/review-panel-new/utils/has-active-range.ts index 0934aef59f..7b636c69a3 100644 --- a/services/web/frontend/js/features/review-panel-new/utils/has-active-range.ts +++ b/services/web/frontend/js/features/review-panel-new/utils/has-active-range.ts @@ -15,8 +15,9 @@ export const hasActiveRange = ( return true } - for (const thread of Object.values(threads)) { - if (!thread.resolved) { + for (const comment of ranges.comments) { + const thread = threads[comment.op.t] + if (thread && !thread.resolved) { return true } } diff --git a/services/web/frontend/js/features/settings/components/emails/actions/remove.tsx b/services/web/frontend/js/features/settings/components/emails/actions/remove.tsx index 136efc04e4..36123cdd1e 100644 --- a/services/web/frontend/js/features/settings/components/emails/actions/remove.tsx +++ b/services/web/frontend/js/features/settings/components/emails/actions/remove.tsx @@ -37,7 +37,7 @@ type RemoveProps = { function Remove({ userEmailData, deleteEmailAsync }: RemoveProps) { const { t } = useTranslation() - const { state, deleteEmail, resetLeaversSurveyExpiration } = + const { state, deleteEmail, resetLeaversSurveyExpiration, setLoading } = useUserEmailsContext() const isManaged = getMeta('ol-isManagedAccount') @@ -62,8 +62,12 @@ function Remove({ userEmailData, deleteEmailAsync }: RemoveProps) { .then(() => { deleteEmail(userEmailData.email) resetLeaversSurveyExpiration(userEmailData) + // Reset the global loading state before this row is unmounted + setLoading(false) + }) + .catch(() => { + setLoading(false) }) - .catch(() => {}) } if (deleteEmailAsync.isLoading) { diff --git a/services/web/frontend/js/features/settings/components/emails/add-email/input.tsx b/services/web/frontend/js/features/settings/components/emails/add-email/input.tsx index e79d652f6b..c5884dbd9c 100644 --- a/services/web/frontend/js/features/settings/components/emails/add-email/input.tsx +++ b/services/web/frontend/js/features/settings/components/emails/add-email/input.tsx @@ -32,6 +32,7 @@ export type DomainInfo = { name: string ssoEnabled?: boolean ssoBeta?: boolean + departments?: string[] } } diff --git a/services/web/frontend/js/features/settings/components/emails/add-email/institution-fields.tsx b/services/web/frontend/js/features/settings/components/emails/add-email/institution-fields.tsx index 49b7de61e7..bb491f4713 100644 --- a/services/web/frontend/js/features/settings/components/emails/add-email/institution-fields.tsx +++ b/services/web/frontend/js/features/settings/components/emails/add-email/institution-fields.tsx @@ -75,16 +75,21 @@ function InstitutionFields({ }, [newEmailMatchedDomain, setRole, setDepartment]) useEffect(() => { + if (newEmailMatchedDomain?.university?.departments?.length) { + setDepartments(newEmailMatchedDomain.university.departments) + return + } + + // fallback if not matched on domain const selectedKnownUniversity = countryCode ? universities[countryCode]?.find(({ name }) => name === universityName) : undefined - if (selectedKnownUniversity && selectedKnownUniversity.departments.length) { setDepartments(selectedKnownUniversity.departments) } else { setDepartments([...defaultDepartments]) } - }, [countryCode, universities, universityName]) + }, [countryCode, universities, universityName, newEmailMatchedDomain]) // Fetch country institution useEffect(() => { diff --git a/services/web/frontend/js/features/settings/components/emails/add-secondary-email-prompt.tsx b/services/web/frontend/js/features/settings/components/emails/add-secondary-email-prompt.tsx index 04bc4edbd1..8355138a14 100644 --- a/services/web/frontend/js/features/settings/components/emails/add-secondary-email-prompt.tsx +++ b/services/web/frontend/js/features/settings/components/emails/add-secondary-email-prompt.tsx @@ -8,6 +8,7 @@ import MaterialIcon from '@/shared/components/material-icon' import { sendMB } from '@/infrastructure/event-tracking' import { ReCaptcha2 } from '../../../../shared/components/recaptcha-2' import { useRecaptcha } from '../../../../shared/hooks/use-recaptcha' +import { useLocation } from '@/shared/hooks/use-location' import { postJSON } from '../../../../infrastructure/fetch-json' import RecaptchaConditions from '@/shared/components/recaptcha-conditions' @@ -25,6 +26,7 @@ export function AddSecondaryEmailPrompt() { const [error, setError] = useState() const [isSubmitting, setIsSubmitting] = useState(false) const { ref: recaptchaRef, getReCaptchaToken } = useRecaptcha() + const location = useLocation() if (!isReady) { return null diff --git a/services/web/frontend/js/features/settings/components/emails/confirm-email-form.tsx b/services/web/frontend/js/features/settings/components/emails/confirm-email-form.tsx index d82a43315c..49b114d9c2 100644 --- a/services/web/frontend/js/features/settings/components/emails/confirm-email-form.tsx +++ b/services/web/frontend/js/features/settings/components/emails/confirm-email-form.tsx @@ -9,6 +9,7 @@ import MaterialIcon from '@/shared/components/material-icon' import { sendMB } from '@/infrastructure/event-tracking' import OLFormLabel from '@/features/ui/components/ol/ol-form-label' import OLButton from '@/features/ui/components/ol/ol-button' +import { useLocation } from '@/shared/hooks/use-location' type Feedback = { type: 'input' | 'alert' @@ -181,6 +182,7 @@ export function ConfirmEmailForm({ onSubmit={submitHandler} onInvalid={invalidFormHandler} className="confirm-email-form" + data-testid="confirm-email-form" >
{(feedback?.type === 'alert' || outerErrorDisplay) && ( @@ -267,6 +269,7 @@ function ConfirmEmailSuccessfullForm({ successButtonText: string redirectTo: string }) { + const location = useLocation() const submitHandler = (e: FormEvent) => { e.preventDefault() location.assign(redirectTo) diff --git a/services/web/frontend/js/features/settings/components/leave/modal-content.tsx b/services/web/frontend/js/features/settings/components/leave/modal-content.tsx index 9ac29d790c..ffe9a99263 100644 --- a/services/web/frontend/js/features/settings/components/leave/modal-content.tsx +++ b/services/web/frontend/js/features/settings/components/leave/modal-content.tsx @@ -10,8 +10,6 @@ import { OLModalTitle, } from '@/features/ui/components/ol/ol-modal' -const WRITEFULL_SUPPORT_EMAIL = 'support@writefull.com' - type LeaveModalContentProps = { handleHide: () => void inFlight: boolean @@ -46,13 +44,16 @@ function LeaveModalContentBlock({ />

, + a: ( + // eslint-disable-next-line jsx-a11y/anchor-has-content + + ), }} />

diff --git a/services/web/frontend/js/features/settings/components/leavers-survey-alert.tsx b/services/web/frontend/js/features/settings/components/leavers-survey-alert.tsx index 55ada55ff0..a7668a2fca 100644 --- a/services/web/frontend/js/features/settings/components/leavers-survey-alert.tsx +++ b/services/web/frontend/js/features/settings/components/leavers-survey-alert.tsx @@ -20,7 +20,7 @@ export function LeaversSurveyAlert() { const [hide, setHide] = usePersistedState( 'hideInstitutionalLeaversSurvey', false, - true + { listen: true } ) function handleDismiss() { diff --git a/services/web/frontend/js/features/settings/components/linking/status.tsx b/services/web/frontend/js/features/settings/components/linking/status.tsx index c2c95c68b2..670a272cdf 100644 --- a/services/web/frontend/js/features/settings/components/linking/status.tsx +++ b/services/web/frontend/js/features/settings/components/linking/status.tsx @@ -1,5 +1,6 @@ import { ReactNode } from 'react' -import Icon from '../../../../shared/components/icon' +import MaterialIcon from '@/shared/components/material-icon' +import OLSpinner from '@/features/ui/components/ol/ol-spinner' type Status = 'pending' | 'success' | 'error' @@ -28,29 +29,20 @@ function StatusIcon({ status }: StatusIconProps) { switch (status) { case 'success': return ( - ) case 'error': return ( - ) case 'pending': - return ( - - ) + return default: return null } diff --git a/services/web/frontend/js/features/settings/context/user-email-context.tsx b/services/web/frontend/js/features/settings/context/user-email-context.tsx index 6d470d271e..579c46143d 100644 --- a/services/web/frontend/js/features/settings/context/user-email-context.tsx +++ b/services/web/frontend/js/features/settings/context/user-email-context.tsx @@ -224,7 +224,9 @@ function useUserEmails() { const [ showInstitutionalLeaversSurveyUntil, setShowInstitutionalLeaversSurveyUntil, - ] = usePersistedState('showInstitutionalLeaversSurveyUntil', 0, true) + ] = usePersistedState('showInstitutionalLeaversSurveyUntil', 0, { + listen: true, + }) const [state, unsafeDispatch] = useReducer(reducer, initialState) const dispatch = useSafeDispatch(unsafeDispatch) const { data, isLoading, isError, isSuccess, runAsync } = @@ -312,13 +314,13 @@ type UserEmailsProviderProps = { children: React.ReactNode } & Record -function UserEmailsProvider(props: UserEmailsProviderProps) { +export function UserEmailsProvider(props: UserEmailsProviderProps) { const value = useUserEmails() return } -const useUserEmailsContext = () => { +export const useUserEmailsContext = () => { const context = useContext(UserEmailsContext) if (context === undefined) { @@ -328,6 +330,4 @@ const useUserEmailsContext = () => { return context } -type EmailContextType = ReturnType - -export { UserEmailsProvider, useUserEmailsContext, EmailContextType } +export type EmailContextType = ReturnType diff --git a/services/web/frontend/js/features/share-project-modal/components/add-collaborators.tsx b/services/web/frontend/js/features/share-project-modal/components/add-collaborators.tsx index 8606fb11fa..157f23b116 100644 --- a/services/web/frontend/js/features/share-project-modal/components/add-collaborators.tsx +++ b/services/web/frontend/js/features/share-project-modal/components/add-collaborators.tsx @@ -23,9 +23,10 @@ export default function AddCollaborators({ readOnly }: { readOnly?: boolean }) { const { t } = useTranslation() - const { updateProject, setInFlight, setError } = useShareProjectContext() + const { setInFlight, setError } = useShareProjectContext() - const { _id: projectId, members, invites, features } = useProjectContext() + const { projectId, project, features, updateProject } = useProjectContext() + const { members, invites } = project || {} const currentMemberEmails = useMemo( () => (members || []).map(member => member.email).sort(), @@ -82,9 +83,7 @@ export default function AddCollaborators({ readOnly }: { readOnly?: boolean }) { let data try { - const invite = (invites || []).find( - invite => invite.email === normalisedEmail - ) + const invite = invites?.find(invite => invite.email === normalisedEmail) if (invite) { data = await resendInvite(projectId, invite) @@ -109,8 +108,8 @@ export default function AddCollaborators({ readOnly }: { readOnly?: boolean }) { // invitation is only populated on successful invite, meaning that for paywall and other cases this will be null successful_invite: !!data.invite, users_updated: !!(data.users || data.user), - current_collaborators_amount: members.length, - current_invites_amount: invites.length, + current_collaborators_amount: members?.length || 0, + current_invites_amount: invites?.length || 0, role, previousEditorsAmount, previousReviewersAmount, @@ -144,15 +143,15 @@ export default function AddCollaborators({ readOnly }: { readOnly?: boolean }) { setInFlight(false) } else if (data.invite) { updateProject({ - invites: invites.concat(data.invite), + invites: invites?.concat(data.invite) || [data.invite], }) } else if (data.users) { updateProject({ - members: members.concat(data.users), + members: members?.concat(data.users) || data.users, }) } else if (data.user) { updateProject({ - members: members.concat(data.user), + members: members?.concat(data.user) || [data.user], }) } @@ -176,24 +175,34 @@ export default function AddCollaborators({ readOnly }: { readOnly?: boolean }) { ]) const privilegeOptions = useMemo(() => { - return [ + const options: { + key: string + label: string + description?: string | null + }[] = [ { key: 'readAndWrite', label: t('editor'), }, - { + ] + + if (features.trackChangesVisible) { + options.push({ key: 'review', label: t('reviewer'), description: !features.trackChanges ? t('comment_only_upgrade_for_track_changes') : null, - }, - { - key: 'readOnly', - label: t('viewer'), - }, - ] - }, [features.trackChanges, t]) + }) + } + + options.push({ + key: 'readOnly', + label: t('viewer'), + }) + + return options + }, [features.trackChanges, features.trackChangesVisible, t]) return ( @@ -207,7 +216,7 @@ export default function AddCollaborators({ readOnly }: { readOnly?: boolean }) { -
+
{ const { t } = useTranslation() const view = useCodeMirrorViewContext() const { dispatch } = useFigureModalContext() - const { _id: projectId } = useProjectContext() + const { projectId } = useProjectContext() const { loading: projectsLoading, data: projects, error } = useUserProjects() const [selectedProject, setSelectedProject] = useState(null) const { hasLinkedProjectFileFeature, hasLinkedProjectOutputFileFeature } = diff --git a/services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-upload-source.tsx b/services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-upload-source.tsx index 94877fb233..b92398ef4b 100644 --- a/services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-upload-source.tsx +++ b/services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-upload-source.tsx @@ -34,7 +34,7 @@ export const FigureModalUploadFileSource: FC = () => { const { t } = useTranslation() const view = useCodeMirrorViewContext() const { dispatch, pastedImageData } = useFigureModalContext() - const { _id: projectId } = useProjectContext() + const { projectId } = useProjectContext() const { rootFile } = useCurrentProjectFolders() const [folder, setFolder] = useState(null) const [nameDirty, setNameDirty] = useState(false) diff --git a/services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-url-source.tsx b/services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-url-source.tsx index 2db449e172..c93ebd5c94 100644 --- a/services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-url-source.tsx +++ b/services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-url-source.tsx @@ -46,7 +46,7 @@ export const FigureModalUrlSource: FC = () => { const [url, setUrl] = useState('') const [nameDirty, setNameDirty] = useState(false) const [name, setName] = useState('') - const { _id: projectId } = useProjectContext() + const { projectId } = useProjectContext() const { rootFile } = useCurrentProjectFolders() const [folder, setFolder] = useState(rootFile) diff --git a/services/web/frontend/js/features/source-editor/components/paste-html/pasted-content-menu.tsx b/services/web/frontend/js/features/source-editor/components/paste-html/pasted-content-menu.tsx index 7fed825d33..11ed441e33 100644 --- a/services/web/frontend/js/features/source-editor/components/paste-html/pasted-content-menu.tsx +++ b/services/web/frontend/js/features/source-editor/components/paste-html/pasted-content-menu.tsx @@ -7,7 +7,6 @@ import { useRef, useState, } from 'react' -import Icon from '../../../../shared/components/icon' import { useTranslation } from 'react-i18next' import { EditorView } from '@codemirror/view' import { PastedContent } from '../../extensions/visual/pasted-content' @@ -135,7 +134,7 @@ export const PastedContentMenu: FC<{ }} > - + {t('paste_with_formatting')} @@ -155,7 +154,7 @@ export const PastedContentMenu: FC<{ }} > - + {t('paste_without_formatting')} diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/column-width-modal/modal.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/column-width-modal/modal.tsx index cc56059182..d23031f7bd 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/column-width-modal/modal.tsx +++ b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/column-width-modal/modal.tsx @@ -163,7 +163,10 @@ const ColumnWidthModalBody = () => { } -function UpgradeBenefits() { +function UpgradeBenefits({ className }: { className?: string }) { const { t } = useTranslation() return ( -
    +
    •   @@ -19,7 +20,7 @@ function UpgradeBenefits() {
    •   - {t('collabs_per_proj', { collabcount: 'Multiple' })} + {t('collabs_per_proj_multiple')}
    • diff --git a/services/web/frontend/js/shared/context/editor-context.tsx b/services/web/frontend/js/shared/context/editor-context.tsx index 2ebab1be7b..d3d56fe64a 100644 --- a/services/web/frontend/js/shared/context/editor-context.tsx +++ b/services/web/frontend/js/shared/context/editor-context.tsx @@ -9,15 +9,12 @@ import { useMemo, useState, } from 'react' -import useScopeValue from '../hooks/use-scope-value' import useBrowserWindow from '../hooks/use-browser-window' -import { useIdeContext } from './ide-context' import { useProjectContext } from './project-context' import { useDetachContext } from './detach-context' import getMeta from '../../utils/meta' import { useUserContext } from './user-context' import { saveProjectSettings } from '@/features/editor-left-menu/utils/api' -import { PermissionsLevel } from '@/features/ide-react/types/permissions' import { useModalsContext } from '@/features/ide-react/context/modals-context' import { WritefullAPI } from './types/writefull-instance' import { Cobranding } from '../../../../types/cobranding' @@ -28,19 +25,14 @@ export const EditorContext = createContext< cobranding?: Cobranding hasPremiumCompile?: boolean renameProject: (newName: string) => void - setPermissionsLevel: (permissionsLevel: PermissionsLevel) => void - showSymbolPalette?: boolean - toggleSymbolPalette?: () => void insertSymbol?: (symbol: SymbolWithCharacter) => void isProjectOwner: boolean isRestrictedTokenMember?: boolean isPendingEditor: boolean - permissionsLevel: PermissionsLevel deactivateTutorial: (tutorial: string) => void inactiveTutorials: string[] currentPopup: string | null setCurrentPopup: Dispatch> - setOutOfSync: (value: boolean) => void hasPremiumSuggestion: boolean setHasPremiumSuggestion: (value: boolean) => void setPremiumSuggestionResetDate: (date: Date) => void @@ -52,12 +44,18 @@ export const EditorContext = createContext< >(undefined) export const EditorProvider: FC = ({ children }) => { - const { socket } = useIdeContext() const { id: userId, featureUsage } = useUserContext() const { role } = useDetachContext() const { showGenericMessageModal } = useModalsContext() - const { owner, features, _id: projectId, members } = useProjectContext() + const { + features, + projectId, + project, + name: projectName, + updateProject, + } = useProjectContext() + const { owner, members } = project || {} const cobranding = useMemo(() => { const brandVariation = getMeta('ol-brandVariation') @@ -72,17 +70,11 @@ export const EditorProvider: FC = ({ children }) => { partner: brandVariation.partner, brandedMenu: brandVariation.branded_menu, submitBtnHtml: brandVariation.submit_button_html, + submitBtnHtmlNoBreaks: brandVariation.submit_button_html_no_br, } ) }, []) - const [projectName, setProjectName] = useScopeValue('project.name') - const [permissionsLevel, setPermissionsLevel] = - useScopeValue('permissionsLevel') - const [outOfSync, setOutOfSync] = useState(false) - const [showSymbolPalette] = useScopeValue('editor.showSymbolPalette') - const [toggleSymbolPalette] = useScopeValue('editor.toggleSymbolPalette') - const [inactiveTutorials, setInactiveTutorials] = useState( () => getMeta('ol-inactiveTutorials') || [] ) @@ -105,10 +97,12 @@ export const EditorProvider: FC = ({ children }) => { const isPendingEditor = useMemo( () => - members?.some( - member => - member._id === userId && - (member.pendingEditor || member.pendingReviewer) + Boolean( + members?.some( + member => + member._id === userId && + (member.pendingEditor || member.pendingReviewer) + ) ), [members, userId] ) @@ -120,33 +114,25 @@ export const EditorProvider: FC = ({ children }) => { [inactiveTutorials] ) - useEffect(() => { - if (socket) { - socket.on('projectNameUpdated', setProjectName) - return () => socket.removeListener('projectNameUpdated', setProjectName) - } - }, [socket, setProjectName]) - const renameProject = useCallback( (newName: string) => { - setProjectName((oldName: string) => { - if (oldName !== newName) { - saveProjectSettings(projectId, { name: newName }).catch( - (response: any) => { - setProjectName(oldName) - const { data, status } = response + const oldName = projectName + if (newName !== oldName) { + updateProject({ name: newName }) + saveProjectSettings(projectId, { name: newName }).catch( + (response: any) => { + updateProject({ name: oldName }) + const { data, status } = response - showGenericMessageModal( - 'Error renaming project', - status === 400 ? data : 'Please try again in a moment' - ) - } - ) - } - return newName - }) + showGenericMessageModal( + 'Error renaming project', + status === 400 ? data : 'Please try again in a moment' + ) + } + ) + } }, - [setProjectName, projectId, showGenericMessageModal] + [projectName, updateProject, projectId, showGenericMessageModal] ) const { setTitle } = useBrowserWindow() @@ -186,19 +172,14 @@ export const EditorProvider: FC = ({ children }) => { cobranding, hasPremiumCompile: features?.compileGroup === 'priority', renameProject, - permissionsLevel: outOfSync ? 'readOnly' : permissionsLevel, - setPermissionsLevel, isProjectOwner: owner?._id === userId, isRestrictedTokenMember: getMeta('ol-isRestrictedTokenMember'), isPendingEditor, - showSymbolPalette, - toggleSymbolPalette, insertSymbol, inactiveTutorials, deactivateTutorial, currentPopup, setCurrentPopup, - setOutOfSync, hasPremiumSuggestion, setHasPremiumSuggestion, premiumSuggestionResetDate, @@ -212,18 +193,12 @@ export const EditorProvider: FC = ({ children }) => { owner, userId, renameProject, - permissionsLevel, - setPermissionsLevel, isPendingEditor, - showSymbolPalette, - toggleSymbolPalette, insertSymbol, inactiveTutorials, deactivateTutorial, currentPopup, setCurrentPopup, - outOfSync, - setOutOfSync, hasPremiumSuggestion, setHasPremiumSuggestion, premiumSuggestionResetDate, @@ -237,6 +212,7 @@ export const EditorProvider: FC = ({ children }) => { {children} ) } + export function useEditorContext() { const context = useContext(EditorContext) diff --git a/services/web/frontend/js/shared/context/file-tree-data-context.tsx b/services/web/frontend/js/shared/context/file-tree-data-context.tsx index 37de214301..be48f52776 100644 --- a/services/web/frontend/js/shared/context/file-tree-data-context.tsx +++ b/services/web/frontend/js/shared/context/file-tree-data-context.tsx @@ -8,7 +8,6 @@ import { FC, useEffect, } from 'react' -import useScopeValue from '../hooks/use-scope-value' import { renameInTree, deleteInTree, @@ -18,9 +17,8 @@ import { import { countFiles } from '../../features/file-tree/util/count-in-tree' import useDeepCompareEffect from '../../shared/hooks/use-deep-compare-effect' import { docsInFolder } from '@/features/file-tree/util/docs-in-folder' -import useScopeValueSetterOnly from '@/shared/hooks/use-scope-value-setter-only' +import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context' import { Folder } from '../../../../types/folder' -import { Project } from '../../../../types/project' import { MainDocument } from '../../../../types/project-settings' import { FindResult } from '@/features/file-tree/util/path' import { @@ -28,6 +26,9 @@ import { useSnapshotContext, } from '@/features/ide-react/context/snapshot-context' import importOverleafModules from '../../../macros/import-overleaf-module.macro' +import { useProjectContext } from '@/shared/context/project-context' +import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context' + const { buildFileTree, createFolder } = (importOverleafModules('snapshotUtils')[0] ?.import as typeof StubSnapshotUtils) || StubSnapshotUtils @@ -61,6 +62,7 @@ enum ACTION_TYPES { MOVE = 'MOVE', CREATE = 'CREATE', } + /* eslint-enable no-unused-vars */ type Action = @@ -179,10 +181,9 @@ export function useFileTreeData() { export const FileTreeDataProvider: FC = ({ children, }) => { - const [project] = useScopeValue('project') - const [currentDocumentId] = useScopeValue('editor.open_doc_id') - const [, setOpenDocName] = useScopeValueSetterOnly('editor.open_doc_name') - const [permissionsLevel] = useScopeValue('permissionsLevel') + const { project } = useProjectContext() + const { currentDocumentId, setOpenDocName } = useEditorOpenDocContext() + const { permissionsLevel } = useIdeReactContext() const { fileTreeFromHistory, snapshot, snapshotVersion } = useSnapshotContext() const fileTreeReadOnly = @@ -193,7 +194,7 @@ export const FileTreeDataProvider: FC = ({ useEffect(() => { if (fileTreeFromHistory) return setRootFolder(project?.rootFolder) - }, [project, fileTreeFromHistory]) + }, [project?.rootFolder, fileTreeFromHistory]) useEffect(() => { if (!fileTreeFromHistory) return diff --git a/services/web/frontend/js/shared/context/ide-context.tsx b/services/web/frontend/js/shared/context/ide-context.tsx index 1b4d83d56c..f882680583 100644 --- a/services/web/frontend/js/shared/context/ide-context.tsx +++ b/services/web/frontend/js/shared/context/ide-context.tsx @@ -8,8 +8,8 @@ export type Ide = { } type IdeContextValue = Ide & { - scopeStore: ScopeValueStore scopeEventEmitter: ScopeEventEmitter + unstableStore: ScopeValueStore } export const IdeContext = createContext(undefined) @@ -17,16 +17,15 @@ export const IdeContext = createContext(undefined) export const IdeProvider: FC< React.PropsWithChildren<{ ide: Ide - scopeStore: ScopeValueStore scopeEventEmitter: ScopeEventEmitter + unstableStore: ScopeValueStore }> -> = ({ ide, scopeStore, scopeEventEmitter, children }) => { +> = ({ ide, scopeEventEmitter, unstableStore, children }) => { /** - * Expose scopeStore via `window.overleaf.unstable.store`, so it can be accessed by external extensions. + * Expose unstableStore via `window.overleaf.unstable.store`, so it can be accessed by external extensions. * * These properties are expected to be available: * - `editor.view` - * - `project.spellcheckLanguage` * - `editor.open_doc_name`, * - `editor.open_doc_id`, * - `settings.theme` @@ -40,18 +39,18 @@ export const IdeProvider: FC< ...window.overleaf, unstable: { ...window.overleaf?.unstable, - store: scopeStore, + store: unstableStore, }, } - }, [scopeStore]) + }, [unstableStore]) const value = useMemo(() => { return { ...ide, - scopeStore, scopeEventEmitter, + unstableStore, } - }, [ide, scopeStore, scopeEventEmitter]) + }, [ide, scopeEventEmitter, unstableStore]) return {children} } diff --git a/services/web/frontend/js/shared/context/layout-context.tsx b/services/web/frontend/js/shared/context/layout-context.tsx index 050e679de7..c3647d3926 100644 --- a/services/web/frontend/js/shared/context/layout-context.tsx +++ b/services/web/frontend/js/shared/context/layout-context.tsx @@ -8,8 +8,8 @@ import { SetStateAction, FC, useState, + useRef, } from 'react' -import useScopeValue from '../hooks/use-scope-value' import useDetachLayout from '../hooks/use-detach-layout' import localStorage from '../../infrastructure/local-storage' import getMeta from '../../utils/meta' @@ -21,40 +21,48 @@ import useEventListener from '@/shared/hooks/use-event-listener' import { isMac } from '@/shared/utils/os' import { sendSearchEvent } from '@/features/event-tracking/search-events' import { useRailContext } from '@/features/ide-redesign/contexts/rail-context' +import usePersistedState from '@/shared/hooks/use-persisted-state' +import { repositionAllTooltips } from '@/features/source-editor/extensions/tooltips-reposition' export type IdeLayout = 'sideBySide' | 'flat' export type IdeView = 'editor' | 'file' | 'pdf' | 'history' -export type LayoutContextValue = { +export type LayoutContextOwnStates = { + view: IdeView | null + chatIsOpen: boolean + reviewPanelOpen: boolean + miniReviewPanelVisible: boolean + leftMenuShown: boolean + loadingStyleSheet: boolean + pdfLayout: IdeLayout + projectSearchIsOpen: boolean + openFile: BinaryFile | null +} + +export type LayoutContextValue = LayoutContextOwnStates & { reattach: () => void detach: () => void detachIsLinked: boolean detachRole: DetachRole changeLayout: (newLayout: IdeLayout, newView?: IdeView) => void - view: IdeView | null setView: (view: IdeView | null) => void - chatIsOpen: boolean setChatIsOpen: Dispatch> - reviewPanelOpen: boolean setReviewPanelOpen: Dispatch< SetStateAction > - miniReviewPanelVisible: boolean setMiniReviewPanelVisible: Dispatch< SetStateAction > - leftMenuShown: boolean setLeftMenuShown: Dispatch< SetStateAction > - loadingStyleSheet: boolean setLoadingStyleSheet: Dispatch< SetStateAction > - pdfLayout: IdeLayout pdfPreviewOpen: boolean - projectSearchIsOpen: boolean setProjectSearchIsOpen: Dispatch> + setOpenFile: Dispatch> + restoreView: () => void } const debugPdfDetach = getMeta('ol-debugPdfDetach') @@ -70,13 +78,17 @@ function setLayoutInLocalStorage(pdfLayout: IdeLayout) { ) } +const reviewPanelStorageKey = `ui.reviewPanelOpen.${getMeta('ol-project_id')}` + export const LayoutProvider: FC = ({ children }) => { // what to show in the "flat" view (editor or pdf) - const [view, _setView] = useScopeValue('ui.view') - const [openFile] = useScopeValue('openFile') + const [view, _setView] = useState('editor') + const [openFile, setOpenFile] = useState(null) const historyToggleEmitter = useScopeEventEmitter('history:toggle', true) const { isOpen: railIsOpen, setIsOpen: setRailIsOpen } = useRailContext() const [prevRailIsOpen, setPrevRailIsOpen] = useState(railIsOpen) + // Whether we came from a file or a document when we left the ide + const lastIdeView = useRef('editor') const setView = useCallback( (value: IdeView | null) => { @@ -95,12 +107,8 @@ export const LayoutProvider: FC = ({ children }) => { setRailIsOpen(prevRailIsOpen) } - if (value === 'editor' && openFile) { - // if a file is currently opened, ensure the view is 'file' instead of - // 'editor' when the 'editor' view is requested. This is to ensure - // that the entity selected in the file tree is the one visible and - // that docs don't take precedence over files. - return 'file' + if (value === 'editor' || value === 'file') { + lastIdeView.current = value } return value @@ -109,7 +117,6 @@ export const LayoutProvider: FC = ({ children }) => { [ _setView, setRailIsOpen, - openFile, historyToggleEmitter, prevRailIsOpen, setPrevRailIsOpen, @@ -117,20 +124,28 @@ export const LayoutProvider: FC = ({ children }) => { ] ) + const restoreView = useCallback(() => { + setView(lastIdeView.current ?? 'editor') + }, [setView]) + // whether the chat pane is open - const [chatIsOpen, setChatIsOpen] = useScopeValue('ui.chatOpen') + const [chatIsOpen, setChatIsOpen] = usePersistedState( + 'ui.chatOpen', + false + ) // whether the review pane is open - const [reviewPanelOpen, setReviewPanelOpen] = - useScopeValue('ui.reviewPanelOpen') + const [reviewPanelOpen, setReviewPanelOpen] = usePersistedState( + reviewPanelStorageKey, + false + ) // whether the review pane is collapsed const [miniReviewPanelVisible, setMiniReviewPanelVisible] = - useScopeValue('ui.miniReviewPanelVisible') + useState(false) // whether the menu pane is open - const [leftMenuShown, setLeftMenuShown] = - useScopeValue('ui.leftMenuShown') + const [leftMenuShown, setLeftMenuShown] = useState(false) // whether the project search is open const [projectSearchIsOpen, setProjectSearchIsOpen] = useState(false) @@ -173,20 +188,34 @@ export const LayoutProvider: FC = ({ children }) => { ) // whether to display the editor and preview side-by-side or full-width ("flat") - const [pdfLayout, setPdfLayout] = useScopeValue('ui.pdfLayout') + const [pdfLayout, setPdfLayout] = useState('sideBySide') // whether stylesheet on theme is loading const [loadingStyleSheet, setLoadingStyleSheet] = useState(false) const changeLayout = useCallback( (newLayout: IdeLayout, newView: IdeView = 'editor') => { + const targetView = newLayout === 'sideBySide' ? 'editor' : newView setPdfLayout(newLayout) - setView(newLayout === 'sideBySide' ? 'editor' : newView) + if (targetView === 'editor') { + restoreView() + } else { + setView(targetView) + } setLayoutInLocalStorage(newLayout) }, - [setPdfLayout, setView] + [setPdfLayout, setView, restoreView] ) + // Force codemirror to reposition all tooltips to prevent an issue + // where tooltips would sometimes show on top of the pdf preview + // https://github.com/overleaf/internal/issues/23840 + useEffect(() => { + if (view === 'pdf' && pdfLayout === 'flat') { + repositionAllTooltips() + } + }, [view, pdfLayout]) + const { reattach, detach, @@ -238,6 +267,7 @@ export const LayoutProvider: FC = ({ children }) => { changeLayout, chatIsOpen, leftMenuShown, + openFile, pdfLayout, pdfPreviewOpen, projectSearchIsOpen, @@ -247,12 +277,14 @@ export const LayoutProvider: FC = ({ children }) => { loadingStyleSheet, setChatIsOpen, setLeftMenuShown, + setOpenFile, setPdfLayout, setReviewPanelOpen, setMiniReviewPanelVisible, setLoadingStyleSheet, setView, view, + restoreView, }), [ reattach, @@ -262,6 +294,7 @@ export const LayoutProvider: FC = ({ children }) => { changeLayout, chatIsOpen, leftMenuShown, + openFile, pdfLayout, pdfPreviewOpen, projectSearchIsOpen, @@ -271,12 +304,14 @@ export const LayoutProvider: FC = ({ children }) => { loadingStyleSheet, setChatIsOpen, setLeftMenuShown, + setOpenFile, setPdfLayout, setReviewPanelOpen, setMiniReviewPanelVisible, setLoadingStyleSheet, setView, view, + restoreView, ] ) diff --git a/services/web/frontend/js/shared/context/local-compile-context.tsx b/services/web/frontend/js/shared/context/local-compile-context.tsx index 3e4dd7a91f..0e938aa881 100644 --- a/services/web/frontend/js/shared/context/local-compile-context.tsx +++ b/services/web/frontend/js/shared/context/local-compile-context.tsx @@ -10,8 +10,6 @@ import { Dispatch, SetStateAction, } from 'react' -import useScopeValue from '../hooks/use-scope-value' -import useScopeValueSetterOnly from '../hooks/use-scope-value-setter-only' import usePersistedState from '../hooks/use-persisted-state' import useAbortController from '../hooks/use-abort-controller' import DocumentCompiler from '../../features/pdf-preview/util/compiler' @@ -39,6 +37,7 @@ import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree- import { useUserSettingsContext } from '@/shared/context/user-settings-context' import { useFeatureFlag } from '@/shared/context/split-test-context' import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context' +import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context' import { getJSON } from '@/infrastructure/fetch-json' import { CompileResponseData } from '../../../../types/compile' import { @@ -50,8 +49,7 @@ import { isSplitTestEnabled } from '@/utils/splitTestUtils' import { captureException } from '@/infrastructure/error-reporter' import OError from '@overleaf/o-error' import getMeta from '@/utils/meta' -import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils' -import { useRailContext } from '@/features/ide-redesign/contexts/rail-context' +import type { Annotation } from '../../../../types/annotation' type PdfFile = Record @@ -75,7 +73,7 @@ export type CompileContext = { warnings: LogEntry[] typesetting: LogEntry[] } - logEntryAnnotations?: Record + logEntryAnnotations?: Record outputFilesArchive?: string pdfDownloadUrl?: string pdfFile?: PdfFile @@ -86,7 +84,7 @@ export type CompileContext = { setAutoCompile: (value: boolean) => void setDraft: (value: any) => void setError: (value: any) => void - setHasLintingError: (value: any) => void // only for storybook + setHasLintingError: (value: boolean) => void // only for storybook setHighlights: (value: any) => void setPosition: Dispatch> setShowCompileTimeWarning: (value: any) => void @@ -99,7 +97,7 @@ export type CompileContext = { stopOnFirstError: boolean stopOnValidationError: boolean stoppedOnFirstError: boolean - uncompiled?: boolean + uncompiled: boolean validationIssues?: Record firstRenderDone: (metrics: { latencyFetch: number @@ -129,23 +127,15 @@ export const LocalCompileProvider: FC = ({ children, }) => { const { hasPremiumCompile, isProjectOwner } = useEditorContext() - const { openDocWithId, openDocs, currentDocument } = useEditorManagerContext() + const { openDocWithId, openDocs } = useEditorManagerContext() + const { currentDocument } = useEditorOpenDocContext() const { role } = useDetachContext() - const newEditor = useIsNewEditorEnabled() - - const { - _id: projectId, - rootDocId, - joinedOnce, - imageName, - compiler: compilerName, - } = useProjectContext() + const { projectId, joinedOnce, project } = useProjectContext() + const { rootDocId, imageName, compiler: compilerName } = project || {} const { pdfPreviewOpen } = useLayoutContext() - const { openTab: openRailTab } = useRailContext() - const { features, alphaProgram, labsProgram } = useUserContext() const { fileTreeData } = useFileTreeData() @@ -160,34 +150,22 @@ export const LocalCompileProvider: FC = ({ const [hasShortCompileTimeout, setHasShortCompileTimeout] = useState(false) // the log entries parsed from the compile output log - const [logEntries, setLogEntries] = useScopeValueSetterOnly('pdf.logEntries') + const [logEntries, setLogEntries] = useState() // annotations for display in the editor, built from the log entries - const [logEntryAnnotations, setLogEntryAnnotations] = useScopeValue( - 'pdf.logEntryAnnotations' - ) + const [logEntryAnnotations, setLogEntryAnnotations] = useState< + undefined | Record + >() // the PDF viewer and whether syntax validation is enabled globally const { userSettings } = useUserSettingsContext() const { pdfViewer, syntaxValidation } = userSettings - // the URL for downloading the PDF - const [, setPdfDownloadUrl] = - useScopeValueSetterOnly('pdf.downloadUrl') - - // the URL for loading the PDF in the preview pane - const [, setPdfUrl] = useScopeValueSetterOnly('pdf.url') - // low level details for metrics const [pdfFile, setPdfFile] = useState() - useEffect(() => { - setPdfDownloadUrl(pdfFile?.pdfDownloadUrl) - setPdfUrl(pdfFile?.pdfUrl) - }, [pdfFile, setPdfDownloadUrl, setPdfUrl]) - // the project is considered to be "uncompiled" if a doc has changed, or finished saving, since the last compile started. - const [uncompiled, setUncompiled] = useScopeValue('pdf.uncompiled') + const [uncompiled, setUncompiled] = useState(false) // whether a doc has been edited since the last compile started const [editedSinceCompileStarted, setEditedSinceCompileStarted] = @@ -265,17 +243,19 @@ export const LocalCompileProvider: FC = ({ const [autoCompile, setAutoCompile] = usePersistedState( `autocompile_enabled:${projectId}`, false, - true + { listen: true } ) // whether the compile should run in draft mode - const [draft, setDraft] = usePersistedState(`draft:${projectId}`, false, true) + const [draft, setDraft] = usePersistedState(`draft:${projectId}`, false, { + listen: true, + }) // whether compiling should stop on first error const [stopOnFirstError, setStopOnFirstError] = usePersistedState( `stop_on_first_error:${projectId}`, false, - true + { listen: true } ) // whether the last compiles stopped on first error @@ -285,11 +265,11 @@ export const LocalCompileProvider: FC = ({ const [stopOnValidationError, setStopOnValidationError] = usePersistedState( `stop_on_validation_error:${projectId}`, true, - true + { listen: true } ) // whether the editor linter found errors - const [hasLintingError, setHasLintingError] = useScopeValue('hasLintingError') + const [hasLintingError, setHasLintingError] = useState(false) // the timestamp that a doc was last changed const [changedAt, setChangedAt] = useState(0) @@ -298,7 +278,7 @@ export const LocalCompileProvider: FC = ({ const cleanupCompileResult = useCallback(() => { setPdfFile(undefined) - setLogEntries(null) + setLogEntries(undefined) setLogEntryAnnotations({}) }, [setPdfFile, setLogEntries, setLogEntryAnnotations]) @@ -517,8 +497,8 @@ export const LocalCompileProvider: FC = ({ // handle log files // asynchronous (TODO: cancel on new compile?) - setLogEntryAnnotations(null) - setLogEntries(null) + setLogEntryAnnotations(undefined) + setLogEntries(undefined) setRawLog(undefined) handleLogFiles(outputFiles, data, abortController.signal).then( @@ -752,22 +732,6 @@ export const LocalCompileProvider: FC = ({ // used for that compile. const lastCompileOptions = useMemo(() => data?.options || {}, [data]) - useEffect(() => { - const listener = () => { - if (newEditor) { - openRailTab('errors') - } else { - setShowLogs(true) - } - } - - window.addEventListener('editor:show-logs', listener) - - return () => { - window.removeEventListener('editor:show-logs', listener) - } - }, [newEditor, openRailTab]) - const value = useMemo( () => ({ animateCompileDropdownArrow, diff --git a/services/web/frontend/js/shared/context/project-context.tsx b/services/web/frontend/js/shared/context/project-context.tsx index f294a88021..8861aba1a5 100644 --- a/services/web/frontend/js/shared/context/project-context.tsx +++ b/services/web/frontend/js/shared/context/project-context.tsx @@ -1,10 +1,31 @@ -import { FC, createContext, useContext, useMemo, useState } from 'react' -import useScopeValue from '../hooks/use-scope-value' +import { + FC, + createContext, + useContext, + useMemo, + useState, + useCallback, +} from 'react' import getMeta from '@/utils/meta' -import { ProjectContextValue } from './types/project-context' +import { ProjectUpdate, ProjectMetadata } from './types/project-metadata' import { ProjectSnapshot } from '@/infrastructure/project-snapshot' +import { Tag } from '../../../../app/src/Features/Tags/types' -const ProjectContext = createContext(undefined) +type ProjectContextValue = { + projectId: ProjectMetadata['_id'] + project: ProjectMetadata | null + joinProject: (project: ProjectMetadata) => void + updateProject: (projectUpdate: ProjectUpdate) => void + joinedOnce: boolean + projectSnapshot: ProjectSnapshot + tags: Tag[] + features: ProjectMetadata['features'] + name: ProjectMetadata['name'] +} + +export const ProjectContext = createContext( + undefined +) export function useProjectContext() { const context = useContext(ProjectContext) @@ -18,35 +39,33 @@ export function useProjectContext() { return context } -// when the provider is created the project is still not added to the Angular -// scope. A few props are populated to prevent errors in existing React -// components -const projectFallback = { - _id: getMeta('ol-project_id'), - name: '', - features: {}, -} - export const ProjectProvider: FC = ({ children }) => { - const [project] = useScopeValue('project') - const joinedOnce = !!project + const [joinedOnce, setJoinedOnce] = useState(false) + const [project, setProject] = useState(null) - const { - _id, - compiler, - imageName, - name, - rootDocId, - members, - invites, - features, - publicAccesLevel: publicAccessLevel, - owner, - trackChangesState, - mainBibliographyDoc_id: mainBibliographyDocId, - } = project || projectFallback + // Expose some project properties with fallbacks for convenience + const projectId = project ? project._id : getMeta('ol-project_id') + const name = project ? project.name : '' + const features = project ? project.features : {} - const [projectSnapshot] = useState(() => new ProjectSnapshot(_id)) + const joinProject = useCallback((projectData: ProjectMetadata) => { + setProject(projectData) + setJoinedOnce(true) + }, []) + + const updateProject = useCallback((projectUpdateData: ProjectUpdate) => { + setProject(projectData => { + // Only perform the update if `project` is already set, otherwise we could + // end up with an incomplete project object + if (!projectData) { + throw new Error('Project not initialized. Use joinProject first.') + } + + return Object.assign({}, projectData, projectUpdateData) + }) + }, []) + + const [projectSnapshot] = useState(() => new ProjectSnapshot(projectId)) const tags = useMemo( () => @@ -56,41 +75,17 @@ export const ProjectProvider: FC = ({ children }) => { [] ) - const value = useMemo(() => { - return { - _id, - compiler, - imageName, - name, - rootDocId, - members, - invites, - features, - publicAccessLevel, - owner, - tags, - trackChangesState, - mainBibliographyDocId, - projectSnapshot, - joinedOnce, - } - }, [ - _id, - compiler, - imageName, - name, - rootDocId, - members, - invites, - features, - publicAccessLevel, - owner, - tags, - trackChangesState, - mainBibliographyDocId, - projectSnapshot, + const value = { + projectId, + project, + joinProject, + updateProject, joinedOnce, - ]) + projectSnapshot, + tags, + features, + name, + } return ( {children} diff --git a/services/web/frontend/js/shared/context/types/project-context.tsx b/services/web/frontend/js/shared/context/types/project-metadata.tsx similarity index 60% rename from services/web/frontend/js/shared/context/types/project-context.tsx rename to services/web/frontend/js/shared/context/types/project-metadata.tsx index 9f68f5e7a3..b0847dfda7 100644 --- a/services/web/frontend/js/shared/context/types/project-context.tsx +++ b/services/web/frontend/js/shared/context/types/project-metadata.tsx @@ -1,9 +1,9 @@ import { UserId } from '../../../../../types/user' import { PublicAccessLevel } from '../../../../../types/public-access-level' -import { ProjectSnapshot } from '@/infrastructure/project-snapshot' -import { Tag } from '../../../../../app/src/Features/Tags/types' +import { ProjectSettings } from '@/features/editor-left-menu/utils/api' +import { Folder } from '../../../../../types/folder' -export type ProjectContextMember = { +export type ProjectMember = { _id: UserId privileges: 'readOnly' | 'readAndWrite' | 'review' email: string @@ -13,15 +13,11 @@ export type ProjectContextMember = { pendingReviewer?: boolean } -export type ProjectContextValue = { +export interface ProjectMetadata extends ProjectSettings { _id: string - name: string - rootDocId?: string mainBibliographyDocId?: string - compiler: string - imageName: string - members: ProjectContextMember[] - invites: ProjectContextMember[] + members: ProjectMember[] + invites: ProjectMember[] features: { collaborators?: number compileGroup?: 'alpha' | 'standard' | 'priority' @@ -44,12 +40,8 @@ export type ProjectContextValue = { privileges: string signUpDate: string } - tags: Tag[] - // TODO: Remove __guests__ and boolean options when we have converted - // all projects to the current format. + rootFolder?: Folder[] trackChangesState: boolean | Record - projectSnapshot: ProjectSnapshot - joinedOnce: boolean } -export type ProjectContextUpdateValue = Partial +export type ProjectUpdate = Partial diff --git a/services/web/frontend/js/shared/context/user-settings-context.tsx b/services/web/frontend/js/shared/context/user-settings-context.tsx index b368371013..cc18c896fa 100644 --- a/services/web/frontend/js/shared/context/user-settings-context.tsx +++ b/services/web/frontend/js/shared/context/user-settings-context.tsx @@ -9,11 +9,11 @@ import { useEffect, } from 'react' -import { UserSettings, Keybindings } from '../../../../types/user-settings' +import { UserSettings } from '../../../../types/user-settings' import getMeta from '@/utils/meta' -import useScopeValue from '@/shared/hooks/use-scope-value' import { userStyles } from '../utils/styles' import { canUseNewEditor } from '@/features/ide-redesign/utils/new-editor-utils' +import { useIdeContext } from '@/shared/context/ide-context' const defaultSettings: UserSettings = { pdfViewer: 'pdfjs', @@ -39,15 +39,6 @@ type UserSettingsContextValue = { > } -type ScopeSettings = { - overallTheme: 'light' | 'dark' - keybindings: Keybindings - fontSize: number - fontFamily: string - lineHeight: number - isNewEditor: boolean -} - export const UserSettingsContext = createContext< UserSettingsContextValue | undefined >(undefined) @@ -60,10 +51,10 @@ export const UserSettingsProvider: FC = ({ ) // update the global scope 'settings' value, for extensions - const [, setScopeSettings] = useScopeValue('settings') + const { unstableStore } = useIdeContext() useEffect(() => { const { fontFamily, lineHeight } = userStyles(userSettings) - setScopeSettings({ + unstableStore.set('settings', { overallTheme: userSettings.overallTheme === 'light-' ? 'light' : 'dark', keybindings: userSettings.mode === 'none' ? 'default' : userSettings.mode, fontFamily, @@ -71,7 +62,7 @@ export const UserSettingsProvider: FC = ({ fontSize: userSettings.fontSize, isNewEditor: canUseNewEditor() && userSettings.enableNewEditor, }) - }, [setScopeSettings, userSettings]) + }, [unstableStore, userSettings]) const value = useMemo( () => ({ diff --git a/services/web/frontend/js/shared/hooks/use-active-overall-theme.tsx b/services/web/frontend/js/shared/hooks/use-active-overall-theme.tsx new file mode 100644 index 0000000000..388c78cf38 --- /dev/null +++ b/services/web/frontend/js/shared/hooks/use-active-overall-theme.tsx @@ -0,0 +1,54 @@ +import { useUserSettingsContext } from '@/shared/context/user-settings-context' +import { OverallTheme } from '@/shared/utils/styles' +import { isIEEEBranded } from '@/utils/is-ieee-branded' +import { useEffect, useMemo, useState } from 'react' + +export type ActiveOverallTheme = 'dark' | 'light' + +const mediaWatcher = window.matchMedia?.('(prefers-color-scheme: dark)') ?? { + // If matchMedia is not supported, use the default (dark) theme + matches: true, + addEventListener: () => {}, + removeEventListener: () => {}, +} + +function getTheme( + overallTheme: OverallTheme, + prefersDark: boolean +): ActiveOverallTheme { + if (isIEEEBranded()) { + return 'dark' + } + if (overallTheme === 'light-') { + return 'light' + } + if (overallTheme === 'system') { + return prefersDark ? 'dark' : 'light' + } + return 'dark' +} + +export const useActiveOverallTheme = (): ActiveOverallTheme => { + const [browserPrefersDarkMode, setBrowserPrefersDarkMode] = useState( + mediaWatcher.matches + ) + const { + userSettings: { overallTheme }, + } = useUserSettingsContext() + + const activeOverallTheme = useMemo(() => { + return getTheme(overallTheme, browserPrefersDarkMode) + }, [overallTheme, browserPrefersDarkMode]) + + useEffect(() => { + const listener = (e: MediaQueryListEvent) => { + setBrowserPrefersDarkMode(e.matches) + } + mediaWatcher.addEventListener('change', listener) + return () => { + mediaWatcher.removeEventListener('change', listener) + } + }, []) + + return activeOverallTheme +} diff --git a/services/web/frontend/js/shared/hooks/use-debounce.ts b/services/web/frontend/js/shared/hooks/use-debounce.ts index 5fee07b0c1..926a08af64 100644 --- a/services/web/frontend/js/shared/hooks/use-debounce.ts +++ b/services/web/frontend/js/shared/hooks/use-debounce.ts @@ -1,12 +1,6 @@ import { useEffect, useState } from 'react' -/** - * @template T - * @param {T} value - * @param {number} delay - * @returns {T} - */ -export default function useDebounce(value: T, delay = 0) { +export default function useDebounce(value: T, delay = 0): T { const [debouncedValue, setDebouncedValue] = useState(value) useEffect(() => { diff --git a/services/web/frontend/js/shared/hooks/use-exposed-state.ts b/services/web/frontend/js/shared/hooks/use-exposed-state.ts new file mode 100644 index 0000000000..781d23698a --- /dev/null +++ b/services/web/frontend/js/shared/hooks/use-exposed-state.ts @@ -0,0 +1,19 @@ +import { type Dispatch, type SetStateAction, useState } from 'react' +import { useUnstableStoreSync } from '@/shared/hooks/use-unstable-store-sync' + +/** + * Creates a state variable that is exposed via window.overleaf.unstable.store, + * which is used by Writefull (and only Writefull). Once Writefull is integrated + * into our codebase, it should be able to hook directly into our React + * contexts and we would then be able to remove this hook, replacing it with + * useState. + */ +export default function useExposedState( + initialState: T | (() => T), + path: string +): [T, Dispatch>] { + const [value, setValue] = useState(initialState) + useUnstableStoreSync(path, value) + + return [value, setValue] +} diff --git a/services/web/frontend/js/shared/hooks/use-persisted-state.ts b/services/web/frontend/js/shared/hooks/use-persisted-state.ts index 1ff452ce4a..cfd2546ada 100644 --- a/services/web/frontend/js/shared/hooks/use-persisted-state.ts +++ b/services/web/frontend/js/shared/hooks/use-persisted-state.ts @@ -7,61 +7,59 @@ import { } from 'react' import _ from 'lodash' import localStorage from '../../infrastructure/local-storage' -import { debugConsole } from '@/utils/debugging' -const safeStringify = (value: unknown) => { - try { - return JSON.stringify(value) - } catch (e) { - debugConsole.error('double stringify exception', e) - return null +type UsePersistedStateOptions = { + listen?: boolean + converter?: { + toPersisted: (value: Value) => PersistedValue + fromPersisted: (persisted: PersistedValue) => Value } } -const safeParse = (value: string) => { - try { - return JSON.parse(value) - } catch (e) { - debugConsole.error('double parse exception', e) - return null - } -} - -function usePersistedState( +function usePersistedState( key: string, - defaultValue?: T, - listen = false, - // The option below is for backward compatibility with Angular - // which sometimes stringifies the values twice - doubleStringifyAndParse = false -): [T, Dispatch>] { + defaultValue?: Value, + options?: UsePersistedStateOptions +): [Value, Dispatch>] { + // Store the default value and options on first render so that they're stable + // and use them on subsequent renders. This is important for, for example, a + // non-primitive default value that should not change on every render. + const [allOptions] = useState<{ + defaultValue?: Value + options?: UsePersistedStateOptions + }>(() => ({ defaultValue, options })) + const listen = allOptions.options?.listen || false + const { toPersisted, fromPersisted } = allOptions.options?.converter || {} + const storedDefaultValue = allOptions.defaultValue + const getItem = useCallback( (key: string) => { const item = localStorage.getItem(key) - return doubleStringifyAndParse ? safeParse(item) : item + return fromPersisted ? fromPersisted(item) : item }, - [doubleStringifyAndParse] + [fromPersisted] ) const setItem = useCallback( - (key: string, value: unknown) => { - const val = doubleStringifyAndParse ? safeStringify(value) : value + (key: string, value: Value) => { + // Nested ternary is convenient for type inference + const val = toPersisted ? toPersisted(value) : value localStorage.setItem(key, val) }, - [doubleStringifyAndParse] + [toPersisted] ) - const [value, setValue] = useState(() => { - return getItem(key) ?? defaultValue + const [value, setValue] = useState(() => { + return getItem(key) ?? storedDefaultValue }) const updateFunction = useCallback( - (newValue: SetStateAction) => { + (newValue: SetStateAction) => { setValue(value => { const actualNewValue = _.isFunction(newValue) ? newValue(value) : newValue - if (actualNewValue === defaultValue) { + if (actualNewValue === storedDefaultValue) { localStorage.removeItem(key) } else { setItem(key, actualNewValue) @@ -70,7 +68,7 @@ function usePersistedState( return actualNewValue }) }, - [key, defaultValue, setItem] + [key, storedDefaultValue, setItem] ) useEffect(() => { @@ -79,7 +77,7 @@ function usePersistedState( if (event.key === key) { // note: this value is read via getItem rather than from event.newValue // because getItem handles deserializing the JSON that's stored in localStorage. - setValue(getItem(key) ?? defaultValue) + setValue(getItem(key) ?? storedDefaultValue) } } @@ -89,7 +87,7 @@ function usePersistedState( window.removeEventListener('storage', listener) } } - }, [defaultValue, key, listen, getItem]) + }, [storedDefaultValue, key, listen, getItem]) return [value, updateFunction] } diff --git a/services/web/frontend/js/shared/hooks/use-scope-value-setter-only.ts b/services/web/frontend/js/shared/hooks/use-scope-value-setter-only.ts deleted file mode 100644 index 27564a1d6a..0000000000 --- a/services/web/frontend/js/shared/hooks/use-scope-value-setter-only.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { - type Dispatch, - type SetStateAction, - useCallback, - useState, -} from 'react' -import { useIdeContext } from '../context/ide-context' -import _ from 'lodash' - -/** - * Similar to `useScopeValue`, but instead of creating a two-way binding, only - * changes in react-> angular direction are propagated, with `value` remaining - * local and independent of its value in the Angular scope. - * - * The interface is compatible with React.useState(), including - * the option of passing a function to the setter. - */ -export default function useScopeValueSetterOnly( - path: string, // dot '.' path of a property in the Angular scope. - defaultValue?: T -): [T | undefined, Dispatch>] { - const { scopeStore } = useIdeContext() - - const [value, setValue] = useState(defaultValue) - - const scopeSetter = useCallback( - (newValue: SetStateAction) => { - setValue(val => { - const actualNewValue = _.isFunction(newValue) ? newValue(val) : newValue - scopeStore.set(path, actualNewValue) - return actualNewValue - }) - }, - [path, scopeStore] - ) - - return [value, scopeSetter] -} diff --git a/services/web/frontend/js/shared/hooks/use-scope-value.ts b/services/web/frontend/js/shared/hooks/use-scope-value.ts deleted file mode 100644 index 594a71f92a..0000000000 --- a/services/web/frontend/js/shared/hooks/use-scope-value.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { - type Dispatch, - type SetStateAction, - useCallback, - useEffect, - useState, -} from 'react' -import _ from 'lodash' -import { useIdeContext } from '../context/ide-context' - -/** - * Binds a property in an Angular scope making it accessible in a React - * component. The interface is compatible with React.useState(), including - * the option of passing a function to the setter. - * - * The generic type is not an actual guarantee because the value for a path is - * returned as undefined when there is nothing in the scope store for that path. - */ -export default function useScopeValue( - path: string // dot '.' path of a property in the Angular scope -): [T, Dispatch>] { - const { scopeStore } = useIdeContext() - - const [value, setValue] = useState(() => scopeStore.get(path)) - - useEffect(() => { - return scopeStore.watch(path, (newValue: T) => { - // NOTE: this is deliberately wrapped in a function, - // to avoid calling setValue directly with a value that's a function - setValue(() => newValue) - }) - }, [path, scopeStore]) - - const scopeSetter = useCallback( - (newValue: SetStateAction) => { - setValue(val => { - const actualNewValue = _.isFunction(newValue) ? newValue(val) : newValue - scopeStore.set(path, actualNewValue) - return actualNewValue - }) - }, - [path, scopeStore] - ) - - return [value, scopeSetter] -} diff --git a/services/web/frontend/js/shared/hooks/use-stop-on-first-error.ts b/services/web/frontend/js/shared/hooks/use-stop-on-first-error.ts index 05dc7e36e6..b85c7a0e7d 100644 --- a/services/web/frontend/js/shared/hooks/use-stop-on-first-error.ts +++ b/services/web/frontend/js/shared/hooks/use-stop-on-first-error.ts @@ -10,7 +10,7 @@ type UseStopOnFirstErrorProps = { export function useStopOnFirstError(opts: UseStopOnFirstErrorProps = {}) { const { eventSource } = opts const { stopOnFirstError, setStopOnFirstError } = useCompileContext() - const { _id: projectId } = useProjectContext() + const { projectId } = useProjectContext() type Opts = { projectId: string diff --git a/services/web/frontend/js/shared/hooks/use-unstable-store-sync.ts b/services/web/frontend/js/shared/hooks/use-unstable-store-sync.ts new file mode 100644 index 0000000000..6285159c02 --- /dev/null +++ b/services/web/frontend/js/shared/hooks/use-unstable-store-sync.ts @@ -0,0 +1,11 @@ +import { useIdeContext } from '@/shared/context/ide-context' +import { useEffect } from 'react' + +export function useUnstableStoreSync(path: string, value: T) { + const { unstableStore } = useIdeContext() + + // Update the unstable store whenever the value changes + useEffect(() => { + unstableStore.set(path, value) + }, [unstableStore, path, value]) +} diff --git a/services/web/frontend/js/shared/hooks/use-viewer-permissions.ts b/services/web/frontend/js/shared/hooks/use-viewer-permissions.ts index 5f6e7f1f72..269d094b27 100644 --- a/services/web/frontend/js/shared/hooks/use-viewer-permissions.ts +++ b/services/web/frontend/js/shared/hooks/use-viewer-permissions.ts @@ -1,7 +1,7 @@ -import { useEditorContext } from '../context/editor-context' +import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context' function useViewerPermissions() { - const { permissionsLevel } = useEditorContext() + const { permissionsLevel } = useIdeReactContext() return permissionsLevel === 'readOnly' } diff --git a/services/web/frontend/js/shared/svgs/overleaf-black.svg b/services/web/frontend/js/shared/svgs/overleaf-black.svg new file mode 100644 index 0000000000..ea0678438b --- /dev/null +++ b/services/web/frontend/js/shared/svgs/overleaf-black.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/services/web/frontend/js/shared/svgs/overleaf-white.svg b/services/web/frontend/js/shared/svgs/overleaf-white.svg new file mode 100644 index 0000000000..2ced81aa46 --- /dev/null +++ b/services/web/frontend/js/shared/svgs/overleaf-white.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/services/web/frontend/js/shared/svgs/sparkle-2-stars.svg b/services/web/frontend/js/shared/svgs/sparkle-2-stars.svg new file mode 100644 index 0000000000..5e4fc5e657 --- /dev/null +++ b/services/web/frontend/js/shared/svgs/sparkle-2-stars.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/services/web/frontend/js/shared/utils/styles.ts b/services/web/frontend/js/shared/utils/styles.ts index 2b30b3ed7c..c03f3052f7 100644 --- a/services/web/frontend/js/shared/utils/styles.ts +++ b/services/web/frontend/js/shared/utils/styles.ts @@ -1,4 +1,4 @@ -export type OverallTheme = '' | 'light-' +export type OverallTheme = '' | 'light-' | 'system' export const fontFamilies = { monaco: ['Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'monospace'], diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index 8e1b5bdf61..5eec4e297b 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -44,7 +44,10 @@ import { import { SuggestedLanguage } from '../../../types/system-message' import type { TeamInvite } from '../../../types/team-invite' import { GroupPlans } from '../../../types/subscription/dashboard/group-plans' -import { GroupSSOLinkingStatus } from '../../../types/subscription/sso' +import { + GroupSSOLinkingStatus, + SSOConfig, +} from '../../../types/subscription/sso' import { PasswordStrengthOptions } from '../../../types/password-strength-options' import { Subscription as ProjectDashboardSubscription } from '../../../types/project/dashboard/subscription' import { ThirdPartyIds } from '../../../types/third-party-ids' @@ -55,6 +58,7 @@ import { FooterMetadata } from '@/features/ui/components/types/footer-metadata' import type { ScriptLogType } from '../../../modules/admin-panel/frontend/js/features/script-logs/script-log' import { ActiveExperiment } from './labs-utils' import { Subscription as AdminSubscription } from '../../../types/admin/subscription' +import { AdminCapability } from '../../../types/admin-capabilities' export interface Meta { 'ol-ExposedSettings': ExposedSettings @@ -62,6 +66,7 @@ export interface Meta { string, { annual: string; monthly: string; annualDividedByTwelve: string } > + 'ol-adminCapabilities': AdminCapability[] 'ol-adminSubscription': AdminSubscription 'ol-aiAssistViaWritefullSource': string 'ol-allInReconfirmNotificationPeriods': UserEmailData[] @@ -75,7 +80,6 @@ export interface Meta { // dynamic keys based on permissions 'ol-canUseAddSeatsFeature': boolean 'ol-canUseFlexibleLicensing': boolean - 'ol-canUseFlexibleLicensingForConsolidatedPlans': boolean 'ol-cannot-add-secondary-email': boolean 'ol-cannot-change-password': boolean 'ol-cannot-delete-own-account': boolean @@ -85,7 +89,7 @@ export interface Meta { 'ol-cannot-link-other-third-party-sso': boolean 'ol-cannot-reactivate-subscription': boolean 'ol-cannot-use-ai': boolean - 'ol-capabilities': Array<'dropbox' | 'chat'> + 'ol-capabilities': Array<'dropbox' | 'chat' | 'use-ai'> 'ol-compileSettings': { reducedTimeoutWarning: string compileTimeout: number @@ -121,6 +125,7 @@ export interface Meta { 'ol-groupPlans': GroupPlans 'ol-groupPolicy': GroupPolicy 'ol-groupSSOActive': boolean + 'ol-groupSSOConfig'?: SSOConfig 'ol-groupSSOTestResult': GroupSSOTestResult 'ol-groupSettingsAdvertisedFor': string[] 'ol-groupSettingsEnabledFor': string[] @@ -146,7 +151,6 @@ export interface Meta { 'ol-isCollectionMethodManual': boolean 'ol-isExternalAuthenticationSystemUsed': boolean 'ol-isManagedAccount': boolean - 'ol-isPaywallChangeCompileTimeoutEnabled': boolean 'ol-isProfessional': boolean 'ol-isRegisteredViaGoogle': boolean 'ol-isRestrictedTokenMember': boolean @@ -160,7 +164,7 @@ export interface Meta { 'ol-languages': SpellCheckLanguage[] 'ol-learnedWords': string[] 'ol-legacyEditorThemes': string[] - 'ol-licenseQuantity': number | undefined + 'ol-licenseQuantity'?: number 'ol-loadingText': string 'ol-managedGroupSubscriptions': ManagedGroupSubscription[] 'ol-managedInstitutions': ManagedInstitution[] @@ -180,7 +184,6 @@ export interface Meta { 'ol-notificationsInstitution': InstitutionType[] 'ol-oauthProviders': OAuthProviders 'ol-odcData': OnboardingFormData - 'ol-odcRole': string 'ol-overallThemes': OverallThemeMeta[] 'ol-pages': number 'ol-passwordStrengthOptions': PasswordStrengthOptions @@ -197,7 +200,7 @@ export interface Meta { 'ol-preventCompileOnLoad'?: boolean 'ol-primaryEmail': { email: string; confirmed: boolean } 'ol-project': any // TODO - 'ol-projectEntityCounts'?: { files: number; docs: number } + 'ol-projectEntityCounts': { files: number; docs: number } 'ol-projectHistoryBlobsEnabled': boolean 'ol-projectName': string 'ol-projectOwnerHasPremiumOnPageLoad': boolean @@ -228,6 +231,7 @@ export interface Meta { 'ol-settingsPlans': Plan[] 'ol-shouldAllowEditingDetails': boolean 'ol-shouldLoadHotjar': boolean + 'ol-showAiAssistNotification': boolean 'ol-showAiErrorAssistant': boolean 'ol-showBrlGeoBanner': boolean 'ol-showCouponField': boolean @@ -246,8 +250,8 @@ export interface Meta { 'ol-splitTestVariants': { [name: string]: string } 'ol-ssoDisabled': boolean 'ol-ssoErrorMessage': string + 'ol-stripeAccountId': string 'ol-stripeCustomerId': string - 'ol-stripeUKApiKey': string 'ol-subscription': any // TODO: mixed types, split into two fields 'ol-subscriptionChangePreview': SubscriptionChangePreview 'ol-subscriptionId': string @@ -321,6 +325,7 @@ export default function getMeta(name: T): Meta[T] { value = element.hasAttribute('content') break case 'json': + case 'number': if (!plainTextValue) { // JSON.parse('') throws value = undefined diff --git a/services/web/frontend/stories/decorators/scope.tsx b/services/web/frontend/stories/decorators/scope.tsx index 31f38c79f4..9bde1eebfa 100644 --- a/services/web/frontend/stories/decorators/scope.tsx +++ b/services/web/frontend/stories/decorators/scope.tsx @@ -10,11 +10,9 @@ import useFetchMock from '../hooks/use-fetch-mock' import { useMeta } from '../hooks/use-meta' import SocketIOShim, { SocketIOMock } from '@/ide/connection/SocketIoShim' import { IdeContext } from '@/shared/context/ide-context' -import { - IdeReactContext, - createReactScopeValueStore, -} from '@/features/ide-react/context/ide-react-context' +import { IdeReactContext } from '@/features/ide-react/context/ide-react-context' import { IdeEventEmitter } from '@/features/ide-react/create-ide-event-emitter' +import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store' import { ReactScopeEventEmitter } from '@/features/ide-react/scope-event-emitter/react-scope-event-emitter' import { ConnectionContext } from '@/features/ide-react/context/connection-context' import { Socket } from '@/features/ide-react/connection/types/socket' @@ -56,30 +54,6 @@ const project: Project = { ], } -const initialScope = { - user, - project, - ui: { - chatOpen: true, - pdfLayout: 'flat', - }, - settings: { - pdfViewer: 'js', - syntaxValidation: true, - }, - editor: { - richText: false, - sharejs_doc: { - doc_id: 'test-doc', - getSnapshot: () => 'some doc content', - hasBufferedOps: () => false, - }, - open_doc_name: 'testfile.tex', - }, - hasLintingError: false, - permissionsLevel: 'owner', -} - const socket = new SocketIOShim.SocketShimNoop( new SocketIOMock() ) as unknown as Socket @@ -191,27 +165,27 @@ const IdeReactProvider: FC = ({ children }) => { setStartedFreeTrial, reportError: () => {}, projectJoined: true, + permissionsLevel: 'owner' as const, + setPermissionsLevel: () => {}, + setOutOfSync: () => {}, })) const [ideContextValue] = useState(() => { - const scopeStore = createReactScopeValueStore(projectId) - for (const [key, value] of Object.entries(initialScope)) { - scopeStore.set(key, value) - } const scopeEventEmitter = new ReactScopeEventEmitter(new IdeEventEmitter()) + const unstableStore = new ReactScopeValueStore() window.overleaf = { ...window.overleaf, unstable: { ...window.overleaf?.unstable, - store: scopeStore, + store: unstableStore, }, } return { socket, - scopeStore, scopeEventEmitter, + unstableStore, } }) diff --git a/services/web/frontend/stories/file-view/file-view.stories.jsx b/services/web/frontend/stories/file-view/file-view.stories.jsx index 5622e6eb52..80c4363c9d 100644 --- a/services/web/frontend/stories/file-view/file-view.stories.jsx +++ b/services/web/frontend/stories/file-view/file-view.stories.jsx @@ -35,6 +35,7 @@ const setupFetchMock = fetchMock => { const fileData = { id: 'file-id', name: 'file.tex', + hash: 'c0ffee', created: new Date().toISOString(), } diff --git a/services/web/frontend/stories/fixtures/compile.js b/services/web/frontend/stories/fixtures/compile.js index bc7ebfae8b..e31a4488e4 100644 --- a/services/web/frontend/stories/fixtures/compile.js +++ b/services/web/frontend/stories/fixtures/compile.js @@ -100,7 +100,7 @@ export const mockClearCache = fetchMock => }) export const mockBuildFile = fetchMock => - fetchMock.get('express:/build/:file', url => { + fetchMock.get('express:/build/:file', ({ url }) => { const { pathname } = new URL(url, 'https://example.com') switch (pathname) { diff --git a/services/web/frontend/stories/icon.stories.jsx b/services/web/frontend/stories/icon.stories.jsx deleted file mode 100644 index d0f0d2d885..0000000000 --- a/services/web/frontend/stories/icon.stories.jsx +++ /dev/null @@ -1,50 +0,0 @@ -import Icon from '../js/shared/components/icon' - -export const Type = args => { - return ( - <> - - - - ) -} -Type.args = { - type: 'tasks', -} - -export const Spinner = args => { - return -} -Spinner.args = { - type: 'spinner', - spin: true, -} - -export const FixedWidth = args => { - return -} -FixedWidth.args = { - type: 'tasks', - fw: true, -} - -export const AccessibilityLabel = args => { - return -} -AccessibilityLabel.args = { - type: 'check', - accessibilityLabel: 'Check', -} - -export default { - title: 'Shared / Components / Icon', - component: Icon, -} diff --git a/services/web/frontend/stories/modals/modal.stories.tsx b/services/web/frontend/stories/modals/modal.stories.tsx new file mode 100644 index 0000000000..2bdd9bdad8 --- /dev/null +++ b/services/web/frontend/stories/modals/modal.stories.tsx @@ -0,0 +1,152 @@ +import Button from '@/features/ui/components/bootstrap-5/button' +import type { Meta, StoryObj } from '@storybook/react' +import OLModal, { + OLModalHeader, + OLModalBody, + OLModalFooter, + OLModalTitle, +} from '@/features/ui/components/ol/ol-modal' + +type Story = StoryObj + +export const Default: Story = { + render: args => { + return ( + + + Heading + + +

      + Lorem ipsum dolor sit lorem a amet, consectetur adipiscing elit, sed + do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam. +

      +
      + + + + +
      + ) + }, +} + +export const ModalWithAcknowledgment: Story = { + render: args => { + return ( + + + Acknowledgment + + +

      + System requires an acknowledgment from the user. Usually contains + only one primary button. +

      +
      + + + +
      + ) + }, +} + +export const ModalWithSecondary: Story = { + render: args => { + return ( + + + Heading + + +

      + Lorem ipsum dolor sit lorem a amet, consectetur adipiscing elit, sed + do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam. +

      +
      + + + +
      + ) + }, +} + +export const ModalWithTertiary: Story = { + render: args => { + return ( + + + Heading + + +

      Used for destructive or irreversible actions.

      +
      + + + + + +
      + ) + }, +} + +export const ModalInformative: Story = { + render: args => { + return ( + + + Informative + + +

      + Presents information for the user to be aware of and doesn’t require + any action. +

      +
      +
      + ) + }, +} + +export const ModalDanger: Story = { + render: args => { + return ( + + + Danger + + +

      + Lorem ipsum dolor sit lorem a amet, consectetur adipiscing elit, sed + do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam. +

      +
      + + + + +
      + ) + }, +} + +const meta: Meta = { + title: 'Shared / Components / Modal', + component: OLModal, + argTypes: { + size: { + control: 'radio', + options: ['lg', 'md', 'sm'], + }, + }, +} + +export default meta diff --git a/services/web/frontend/stories/pdf-log-entry.stories.tsx b/services/web/frontend/stories/pdf-log-entry.stories.tsx index 6b60bdff03..12f7594621 100644 --- a/services/web/frontend/stories/pdf-log-entry.stories.tsx +++ b/services/web/frontend/stories/pdf-log-entry.stories.tsx @@ -4,7 +4,7 @@ import { ruleIds } from '@/ide/human-readable-logs/HumanReadableLogsHints' import { ScopeDecorator } from './decorators/scope' import { useMeta } from './hooks/use-meta' import { FC, ReactNode } from 'react' -import { useScope } from './hooks/use-scope' +import { EditorViewContext } from '@/features/ide-react/context/editor-view-context' import { EditorView } from '@codemirror/view' import { LogEntry } from '@/features/pdf-preview/util/types' @@ -58,12 +58,30 @@ export default meta type Story = StoryObj +const MockEditorViewProvider: FC = ({ children }) => { + const value = { + view: new EditorView({ + doc: '\\begin{document', + }), + setView: () => {}, + } + + return ( + + {children} + + ) +} + const Provider: FC> = ({ children, }) => { useMeta({ 'ol-showAiErrorAssistant': true }) - useScope({ 'editor.view': new EditorView({ doc: '\\begin{document' }) }) - return
      {children}
      + return ( + +
      {children}
      +
      + ) } export const PdfLogEntryWithControls: Story = { diff --git a/services/web/frontend/stories/pdf-preview.stories.jsx b/services/web/frontend/stories/pdf-preview.stories.jsx index 0233006a00..df0429734e 100644 --- a/services/web/frontend/stories/pdf-preview.stories.jsx +++ b/services/web/frontend/stories/pdf-preview.stories.jsx @@ -319,7 +319,7 @@ export const HybridToolbar = () => { export const FileList = () => { const fileList = useMemo(() => { - return buildFileList(cloneDeep(outputFiles)) + return buildFileList(cloneDeep(outputFiles), {}) }, []) return ( diff --git a/services/web/frontend/stories/project-list/survey-widget.stories.tsx b/services/web/frontend/stories/project-list/survey-widget-ds-nav.stories.tsx similarity index 55% rename from services/web/frontend/stories/project-list/survey-widget.stories.tsx rename to services/web/frontend/stories/project-list/survey-widget-ds-nav.stories.tsx index c1437a789e..8e9e0d1a40 100644 --- a/services/web/frontend/stories/project-list/survey-widget.stories.tsx +++ b/services/web/frontend/stories/project-list/survey-widget-ds-nav.stories.tsx @@ -1,31 +1,32 @@ -import SurveyWidget from '../../js/features/project-list/components/survey-widget' +import { SurveyWidgetDsNav } from '@/features/project-list/components/survey-widget-ds-nav' export const Survey = (args: any) => { localStorage.clear() window.metaAttributesCache.set('ol-survey', { name: 'my-survey', - preText: 'To help shape the future of Overleaf', - linkText: 'Click here!', + title: 'To help shape the future of Overleaf', + text: 'Click here!', + cta: 'Let’s go!', url: 'https://example.com/my-survey', }) - return + return } export const UndefinedSurvey = (args: any) => { localStorage.clear() - return + return } export const EmptySurvey = (args: any) => { localStorage.clear() window.metaAttributesCache.set('ol-survey', {}) - return + return } export default { title: 'Project List / Survey Widget', - component: SurveyWidget, + component: SurveyWidgetDsNav, } diff --git a/services/web/frontend/stories/source-editor/source-editor.stories.tsx b/services/web/frontend/stories/source-editor/source-editor.stories.tsx index 3cc6b1c95f..3f475944e4 100644 --- a/services/web/frontend/stories/source-editor/source-editor.stories.tsx +++ b/services/web/frontend/stories/source-editor/source-editor.stories.tsx @@ -2,9 +2,115 @@ import SourceEditor from '../../js/features/source-editor/components/source-edit import { ScopeDecorator } from '../decorators/scope' import { useScope } from '../hooks/use-scope' import { useMeta } from '../hooks/use-meta' -import { FC } from 'react' +import React, { FC, useState } from 'react' import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path' import RangesTracker from '@overleaf/ranges-tracker' +import useExposedState from '@/shared/hooks/use-exposed-state' +import { EditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context' +import { DocId } from '../../../types/project-settings' +import { StoryObj } from '@storybook/react' +import { DocumentContainer } from '@/features/ide-react/editor/document-container' +import { EditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context' + +type Story = StoryObj + +const EditorOpenDocProvider: FC< + React.PropsWithChildren<{ + initialOpenDocName: string | null + initialDocument: DocumentContainer + }> +> = ({ children, initialOpenDocName, initialDocument }) => { + const [currentDocumentId, setCurrentDocumentId] = + useExposedState(null, 'editor.open_doc_id') + const [openDocName, setOpenDocName] = useExposedState( + initialOpenDocName, + 'editor.open_doc_name' + ) + const [currentDocument, setCurrentDocument] = + useState(initialDocument) + + const value = { + currentDocumentId, + setCurrentDocumentId, + openDocName, + setOpenDocName, + currentDocument, + setCurrentDocument, + } + + return ( + + {children} + + ) +} + +const LatexEditorOpenDocProvider: FC = ({ + children, +}) => ( + + {children} + +) + +const MarkdownEditorOpenDocProvider: FC = ({ + children, +}) => ( + + {children} + +) + +const BibtexEditorOpenDocProvider: FC = ({ + children, +}) => ( + + {children} + +) + +const VisualEditorPropertiesProvider: FC = ({ + children, +}) => { + const [showVisual, setShowVisual] = useState(true) + + const value = { + showVisual, + setShowVisual, + showSymbolPalette: true, + setShowSymbolPalette: () => undefined, + toggleSymbolPalette: () => undefined, + opening: true, + setOpening: () => undefined, + trackChanges: false, + setTrackChanges: () => undefined, + wantTrackChanges: false, + setWantTrackChanges: () => undefined, + errorState: false, + setErrorState: () => undefined, + } + + return ( + + {children} + + ) +} const FileTreePathProvider: FC = ({ children }) => ( - ScopeDecorator(Story, { - mockCompileOnLoad: true, - providers: { FileTreePathProvider }, - }), (Story: any) => (
      @@ -59,102 +160,141 @@ const permissions = { write: true, } -export const Latex = (args: any, { globals: { theme } }: any) => { - // FIXME: useScope has no effect - useScope({ - editor: { - sharejs_doc: mockDoc(content.tex, changes.tex), - open_doc_name: 'example.tex', - }, - rootFolder: { - name: 'rootFolder', - id: 'root-folder-id', - type: 'folder', - children: [ - { - name: 'example.tex.tex', - id: 'example-doc-id', - type: 'doc', +export const Latex: Story = { + decorators: [ + Story => + ScopeDecorator(Story, { + mockCompileOnLoad: true, + providers: { + FileTreePathProvider, + EditorOpenDocProvider: LatexEditorOpenDocProvider, + }, + }), + + (Story, { globals }) => { + // FIXME: useScope has no effect + useScope({ + rootFolder: { + name: 'rootFolder', + id: 'root-folder-id', + type: 'folder', + children: [ + { + name: 'example.tex.tex', + id: 'example-doc-id', + type: 'doc', + selected: false, + $$hashKey: 'object:89', + }, + { + name: 'frog.jpg', + id: 'frog-image-id', + type: 'file', + linkedFileData: null, + created: '2023-05-04T16:11:04.352Z', + $$hashKey: 'object:108', + }, + ], selected: false, - $$hashKey: 'object:89', }, - { - name: 'frog.jpg', - id: 'frog-image-id', - type: 'file', - linkedFileData: null, - created: '2023-05-04T16:11:04.352Z', - $$hashKey: 'object:108', + settings: { + ...settings, + overallTheme: globals.theme === 'default-' ? '' : globals.theme, }, - ], - selected: false, - }, - settings: { - ...settings, - overallTheme: theme === 'default-' ? '' : theme, - }, - permissions, - }) + permissions, + }) - useMeta({ - 'ol-showSymbolPalette': true, - }) + useMeta({ + 'ol-showSymbolPalette': true, + }) - return + return + }, + ], } -export const Markdown = (args: any, { globals: { theme } }: any) => { - useScope({ - editor: { - sharejs_doc: mockDoc(content.md, changes.md), - open_doc_name: 'example.md', - }, - settings: { - ...settings, - overallTheme: theme === 'default-' ? '' : theme, - }, - permissions, - }) +export const Markdown: Story = { + decorators: [ + Story => + ScopeDecorator(Story, { + mockCompileOnLoad: true, + providers: { + FileTreePathProvider, + EditorOpenDocProvider: MarkdownEditorOpenDocProvider, + }, + }), - return + (Story, { globals }) => { + // FIXME: useScope has no effect + useScope({ + settings: { + ...settings, + overallTheme: globals.theme === 'default-' ? '' : globals.theme, + }, + permissions, + }) + + return + }, + ], } -export const Visual = (args: any, { globals: { theme } }: any) => { - useScope({ - editor: { - sharejs_doc: mockDoc(content.tex, changes.tex), - open_doc_name: 'example.tex', - showVisual: true, - }, - settings: { - ...settings, - overallTheme: theme === 'default-' ? '' : theme, - }, - permissions, - }) - useMeta({ - 'ol-showSymbolPalette': true, - 'ol-mathJaxPath': 'https://unpkg.com/mathjax@3.2.2/es5/tex-svg-full.js', - 'ol-project_id': '63e21c07946dd8c76505f85a', - }) +export const Visual: Story = { + decorators: [ + Story => + ScopeDecorator(Story, { + mockCompileOnLoad: true, + providers: { + FileTreePathProvider, + EditorOpenDocProvider: LatexEditorOpenDocProvider, + EditorPropertiesProvider: VisualEditorPropertiesProvider, + }, + }), - return + (Story, { globals }) => { + // FIXME: useScope has no effect, so this does nothing + useScope({ + settings: { + ...settings, + overallTheme: globals.theme === 'default-' ? '' : globals.theme, + }, + permissions, + }) + + useMeta({ + 'ol-mathJaxPath': 'https://unpkg.com/mathjax@3.2.2/es5/tex-svg-full.js', + 'ol-project_id': '63e21c07946dd8c76505f85a', + }) + + return + }, + ], } -export const Bibtex = (args: any, { globals: { theme } }: any) => { - useScope({ - editor: { - sharejs_doc: mockDoc(content.bib, changes.bib), - open_doc_name: 'example.bib', - }, - settings: { - ...settings, - overallTheme: theme === 'default-' ? '' : theme, - }, - permissions, - }) +export const Bibtex: Story = { + decorators: [ + Story => + ScopeDecorator(Story, { + mockCompileOnLoad: true, + providers: { + FileTreePathProvider, + EditorOpenDocProvider: BibtexEditorOpenDocProvider, + }, + }), - return + (Story, { globals }) => { + // FIXME: useScope has no effect + useScope({ + settings: { + ...settings, + overallTheme: globals.theme === 'default-' ? '' : globals.theme, + }, + permissions, + }) + + return + }, + ], } const MAX_DOC_LENGTH = 2 * 1024 * 1024 // ol-maxDocLength @@ -189,6 +329,9 @@ const mockDoc = (content: string, changes: Array> = []) => { detachFromCM6: () => { // Do nothing }, + getType: () => { + return 'history-ot' + }, on: () => { // Do nothing }, diff --git a/services/web/frontend/stories/ui/badge.stories.tsx b/services/web/frontend/stories/ui/badge.stories.tsx index be4d3a84ab..1b551d3254 100644 --- a/services/web/frontend/stories/ui/badge.stories.tsx +++ b/services/web/frontend/stories/ui/badge.stories.tsx @@ -1,5 +1,5 @@ import Badge from '@/features/ui/components/bootstrap-5/badge' -import Icon from '@/shared/components/icon' +import MaterialIcon from '@/shared/components/material-icon' import type { Meta, StoryObj } from '@storybook/react' import classnames from 'classnames' @@ -49,7 +49,7 @@ export const BadgePrepend: Story = { return ( } + prepend={} {...args} /> ) diff --git a/services/web/frontend/stylesheets/app/editor/pdf.less b/services/web/frontend/stylesheets/app/editor/pdf.less index 545cac0e9d..3531a8289e 100644 --- a/services/web/frontend/stylesheets/app/editor/pdf.less +++ b/services/web/frontend/stylesheets/app/editor/pdf.less @@ -539,8 +539,8 @@ } @editor-and-logs-pane-toolbars-height: @toolbar-small-height + @toolbar-height; -@btn-small-height: (@padding-small-vertical * 2)+ (@font-size-small * - @line-height-small); // 5px * 2 + 14px * 1.5 = 31px +@btn-small-height: (@padding-small-vertical * 2)+ + (@font-size-small * @line-height-small); // 5px * 2 + 14px * 1.5 = 31px #dropdown-files-logs-pane-list { overflow-y: auto; @@ -548,7 +548,7 @@ white-space: nowrap; } max-height: calc( - ~'100vh - ' @editor-and-logs-pane-toolbars-height ~' - ' @btn-small-height ~' - ' - @margin-md + ~'100vh - ' @editor-and-logs-pane-toolbars-height ~' - ' @btn-small-height + ~' - ' @margin-md ); } diff --git a/services/web/frontend/stylesheets/app/plans.less b/services/web/frontend/stylesheets/app/plans.less deleted file mode 100644 index 127f102b6e..0000000000 --- a/services/web/frontend/stylesheets/app/plans.less +++ /dev/null @@ -1,1429 +0,0 @@ -@z-index-plans-new-tabs: 1; -@z-index-group-member-picker-list: 1; -@z-index-plans-new-tabs-content: 0; - -@highlighted-heading-line-height: (@line-height-03 / 1rem) * 16px; // convert to px -@highlighted-heading-padding-vertical: @spacing-02; -@highlighted-heading-height: ( - @highlighted-heading-line-height + (2 * @highlighted-heading-padding-vertical) -); - -@switcher-container-margin-bottom: @highlighted-heading-height + @spacing-10; - -@nondiscounted-price-element-height: var(--line-height-02); - -@group-member-picker-height: 24px; -@group-member-picker-top-height: 36px; -@group-member-picker-sm-width: 50%; -@group-member-picker-md-width: 25%; -@group-member-picker-min-width: 234px; - -@container-plans-responsive-width: 95%; -@table-4-column-width: 25%; -@table-5-column-width: 20%; - -.plans-new-design { - padding-top: var(--header-height); - - .container { - padding: 0 var(--spacing-06); - - @media (min-width: @screen-sm-min) { - width: @container-plans-responsive-width; - } - @media (min-width: @screen-xl-min) { - width: @container-xl; - } - - .geo-banner-container { - margin-top: var(--spacing-08); - } - - .plans-new-design-content-spacing { - margin-top: var(--spacing-16); - } - - .interstitial-new-design-content-spacing { - margin-top: var(--spacing-13); - } - } - .main-heading-section { - text-align: center; - max-width: 885px; - margin-left: auto; - margin-right: auto; - - @media (max-width: @screen-xs-max) { - text-align: left; - padding: 0 16px; - } - - .plans-page-heading { - margin-top: 8px; - margin-bottom: unset; - font-size: 3rem; - font-weight: 600; - line-height: 64px; - @media (max-width: @screen-xs-max) { - font-size: 2.25rem; - line-height: 48px; - padding-right: 5rem; - } - } - - .plans-page-sub-heading { - font-size: 1.125rem; - line-height: 24px; - margin-top: 16px; - margin-bottom: unset; - } - } - - .plans-new-content { - display: flex; - flex-direction: column; - align-items: center; - - @media (min-width: @screen-sm-min) { - border-left: 1px solid var(--neutral-20); - border-right: 1px solid var(--neutral-20); - border-bottom: 1px solid var(--neutral-20); - border-radius: 8px; - } - - // this is the border between the tabs and the content, specifically on the left and right side - // this is necessary to enable top border radius on the plans-new-content - &::before { - content: ''; - display: block; - z-index: @z-index-plans-new-tabs-content; - position: absolute; - top: -1px; // make border overlap with the border on .plans-new-tabs - width: 100%; - height: 20px; // arbitrary height since it's transparent, make sure that it's bigger than border radius - background: transparent; - border-top: 1px solid var(--neutral-20); - - @media (min-width: @screen-sm-min) { - border-top-left-radius: 8px; - border-top-right-radius: 8px; - } - } - } - - .plans-new-tabs-container { - z-index: @z-index-plans-new-tabs; - margin-top: var(--spacing-16); - - // explicit padding to tell that the bottom left and bottom right - // does not have bottom border defined in .plans-new-tabs - // technically unnecessary because padding is already defined in bootstrap column - padding: 0 16px; - } - - .plans-new-tabs { - display: flex; - justify-content: center; - gap: 8px; - border-bottom: 1px solid var(--neutral-20); - - .plans-new-tab { - cursor: pointer; - font-size: 16px; - font-weight: 600; - border-top-right-radius: 8px; - border-top-left-radius: 8px; - - .plans-new-tab-link { - border: unset; - display: flex; - align-items: center; - color: var(--neutral-70); - margin: 0; - border-top-right-radius: 8px; - border-top-left-radius: 8px; - border: 1px solid var(--neutral-20); - padding: var(--spacing-05) var(--spacing-08); - gap: var(--spacing-04); - - &:focus { - background-color: unset; - outline: 0; - } - - &:hover { - background-color: var(--neutral-20); - } - - // tab navigation focus style - &:focus-visible { - .box-shadow-button-input(); - outline: 0; - } - - .group-discount-bubble { - padding: var(--spacing-01) var(--spacing-04); - background-color: var(--green-10); - color: var(--green-50); - border-radius: var(--border-radius-full-new); - font-family: 'DM Mono', monospace; - font-feature-settings: 'ss05'; - font-size: var(--font-size-01); - line-height: var(--line-height-01); - font-weight: 500; - - @media (max-width: @screen-xs-max) { - display: none; - } - } - - @media (max-width: @screen-xs-max) { - font-size: var(--font-size-02); - line-height: var(--line-height-02); - padding: var(--spacing-05); - gap: var(--spacing-02); - } - } - - &.active { - .plans-new-tab-link { - border: 1px solid white; - position: relative; - color: var(--green-50); - - // remove the border on tab focus - &:focus-visible { - &::before { - content: unset; - } - } - - &::before { - content: ''; - position: absolute; - background: border-box - linear-gradient( - to bottom, - @green-50 0%, - @neutral-20 85%, - @neutral-20 100% - ); - -webkit-mask: - linear-gradient(white 0 0) padding-box, - linear-gradient(white 0 0); - mask: - linear-gradient(white 0 0) padding-box, - linear-gradient(white 0 0); - -webkit-mask-composite: xor; - mask-composite: exclude; - border-top-right-radius: 8px; - border-top-left-radius: 8px; - border: 1px solid transparent; - border-bottom: 1px solid white; - - // make the border overlap with the .plans-new-tab-link border - top: 0; - bottom: -2px; - left: -1px; - right: -1px; - } - - &:hover { - background-color: unset; - } - } - - .plans-new-discount-badge { - background-color: var(--green-10); - color: var(--green-60); - } - } - } - } - - .plans-new-period-switcher-container { - position: relative; - display: inline-flex; - background-color: var(--neutral-10); - border-radius: var(--border-radius-full-new); - padding: var(--spacing-03); - margin-top: var(--spacing-09); - margin-bottom: @switcher-container-margin-bottom; - gap: var(--spacing-04); - - @media (max-width: @screen-xs-max) { - margin-bottom: var(--spacing-09); - } - - label { - display: inline-flex; - align-items: center; - margin: 0; - font-size: var(--font-size-05); - font-weight: 600; - line-height: var(--line-height-04); - text-align: center; - padding: var(--spacing-01) var(--spacing-04); - border-radius: var(--border-radius-full-new); - - &:hover { - background-color: var(--neutral-20); - cursor: pointer; - } - } - - input[type='radio'] { - position: absolute; - left: -9999px; - &:focus, - &:focus-visible { - outline: 0; - } - } - - input[type='radio']:focus-visible + label, - input[type='radio']:checked:focus-visible + label { - .box-shadow-button-input(); - } - - input[type='radio']:checked + label { - background-color: var(--green-50); - color: white; - box-shadow: 0px 2px 4px 0px rgba(30, 37, 48, 0.16); - .plans-new-discount-badge { - background-color: var(--green-10); - color: var(--green-60); - } - } - - .plans-new-discount-badge { - margin-left: var(--spacing-03); - } - } - - .plans-new-discount-badge { - font-size: var(--font-size-01); - font-family: 'DM Mono', monospace; - padding: 2px 8px; - height: 20px; - border-radius: 10px; - background-color: var(--neutral-70); - color: white; - display: flex; - align-items: center; - font-weight: 500; - line-height: var(--line-height-01); - } - - .plans-new-tab-content { - width: 100%; - border: none; - padding-top: 0; - - @media (max-width: @screen-xs-min) { - padding: 0; - } - } - - .plans-new-mobile { - display: none; - - @media (max-width: @screen-xs-max) { - display: block; - } - } - - .plans-new-desktop { - display: block; - - @media (max-width: @screen-xs-max) { - display: none; - } - } - - .plans-new-table { - width: 100%; - - // keep the borders separate to help with spacing and alignment in the CTAs - border-collapse: separate; - border-spacing: 0; - - th, - td { - width: @table-4-column-width; - } - } - - .plans-new-table-student { - margin-left: @table-4-column-width / 2; - } - - .plans-new-table-student-verification { - font-weight: 600; - font-size: var(--font-size-01); - text-align: center; - } - - .plans-new-table-group { - margin-top: @spacing-11 + @highlighted-heading-height; - } - - thead th { - position: relative; - padding: var(--spacing-06) var(--spacing-08) var(--spacing-04) - var(--spacing-08); - font-size: var(--font-size-05); - font-weight: 600; - line-height: var(--line-height-04); - color: var(--neutral-90); - text-align: center; - - @media (max-width: @screen-md-max) { - padding: var(--spacing-05) var(--spacing-05) 0 var(--spacing-05); - } - } - - .plans-new-table-subheader { - vertical-align: top; - padding: 0 var(--spacing-08); - @media (max-width: @screen-md-max) { - padding: 0 var(--spacing-05); - } - - &.plans-new-table-icon-cta-cell, - &.plans-subheader-monthly-cta { - vertical-align: bottom; - } - } - - .plans-new-table-cta-row { - td { - padding-bottom: var(--spacing-06); - @media (max-width: @screen-md-max) { - padding-bottom: var(--spacing-05); - } - - // use transparent borders to use the same spacing as highlighted cells - &:not(.plans-new-table-highlighted-cell) { - border-right: var(--border-width-base) solid transparent; - border-left: var(--border-width-base) solid transparent; - } - } - - .btn-block + .btn-block { - margin-top: var(--spacing-04); - } - } - - .plans-new-table-header-grid-container { - display: flex; - flex-direction: column; - align-items: center; - - s, - .match-original-price-height { - font-size: var(--font-size-02); - line-height: @nondiscounted-price-element-height; - color: var(--neutral-60); - font-weight: 600; - } - - .plans-new-table-header-price { - font-size: var(--font-size-08); - font-weight: 600; - line-height: var(--line-height-07); - color: var(--neutral-90); - } - - .plans-new-table-header-price-unit { - font-size: var(--font-size-02); - line-height: var(--line-height-02); - text-align: center; - } - - .plans-new-table-cta { - margin-top: auto; - a:nth-child(2) { - margin-top: var(--spacing-04); - } - } - - .plans-new-table-header-icon { - font-size: 56px; - color: var(--neutral-90); - } - } - - .plans-new-table-header-price-unit-total { - font-size: var(--font-size-01); - line-height: var(--line-height-01); - } - - .plans-new-table-header-desc { - margin-top: var(--spacing-05); - margin-bottom: var(--spacing-08); - font-size: var(--font-size-02); - line-height: var(--line-height-02); - } - - .plans-new-group-member-picker { - .plans-new-group-member-picker-text { - font-size: var(--font-size-02); - line-height: var(--line-height-02); - font-weight: 600; - margin-bottom: var(--spacing-02); - } - - .plans-new-group-member-picker-form { - position: relative; - - .plans-new-group-member-picker-button { - width: 100%; - background-color: white; - border-radius: var(--border-radius-base-new); - border: 1px solid var(--neutral-60); - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--spacing-01) var(--spacing-03); - margin-bottom: var(--spacing-04); - height: @group-member-picker-height; - font-size: var(--font-size-02); - line-height: var(--line-height-02); - - &[aria-expanded='true'] { - i { - transform: rotate(180deg); - transition: transform 0.35s ease; - } - } - - &[aria-expanded='false'] { - i { - transition: transform 0.35s ease; - } - } - - &[data-ol-plans-new-group-member-picker-button='group-all'] { - height: @group-member-picker-top-height; - } - } - - ul.plans-new-group-member-picker-list { - list-style-type: none; - margin-bottom: 0; - overflow: auto; // to enable box-shadow - box-shadow: 0px 2px 4px 0px rgba(30, 37, 48, 0.16); - padding: var(--spacing-02); - position: absolute; - top: @group-member-picker-height; - background-color: white; - width: 100%; - margin-top: var(--spacing-01); - z-index: @z-index-group-member-picker-list; - - &[data-ol-plans-new-group-member-picker-dropdown='group-all'] { - top: @group-member-picker-top-height; - } - } - - li.plans-new-group-member-picker-footer { - font-size: var(--font-size-02); - line-height: var(--line-height-02); - padding: var(--spacing-05) var(--spacing-04); - - span { - display: block; - } - - button { - font-weight: 400; - padding: 0; - font-size: var(--font-size-02); - line-height: var(--line-height-02); - } - } - - li { - position: relative; - - &:not(:last-child) { - margin-bottom: var(--spacing-02); - } - - &:not(.plans-new-group-member-picker-footer):hover { - input[type='radio']:not(:checked) + p { - background-color: var(--neutral-10); - } - } - - input[type='radio'] { - position: absolute; - opacity: 0; - cursor: pointer; - - &:focus + p { - &:extend(.input-focus-style); - } - - + p { - padding: var(--spacing-05) var(--spacing-08) var(--spacing-05) - var(--spacing-04); - margin-bottom: 0; - border-radius: var(--border-radius-base-new); - } - } - - input[type='radio']:checked + p { - background-color: var(--green-10); - color: var(--green-70); - position: relative; - word-wrap: break-word; - - &::after { - content: url(../../../public/img/material-icons/check-green-20.svg); - position: absolute; - right: 12px; - top: 50%; - transform: translateY(-50%); - - @media (max-width: @screen-sm-max) { - right: var(--spacing-04); - } - } - } - - label { - width: 100%; - font-size: var(--font-size-02); - line-height: var(--line-height-02); - margin-bottom: var(--spacing-00); - font-weight: 400; - cursor: pointer; - border-radius: var(--border-radius-base-new); - - .list-item-footer { - font-size: var(--font-size-01); - line-height: var(--line-height-01); - } - } - } - - .plans-new-edu-discount { - display: flex; - align-items: flex-start; - gap: var(--spacing-04); - margin-bottom: var(--spacing-06); - font-weight: 400; - - input[type='checkbox'] { - margin: var(--spacing-02); - accent-color: var(--green-50); - - &:focus-visible { - .box-shadow-button-input(); - } - } - - .plans-new-edu-discount-content { - display: flex; - flex-direction: column; - - span { - line-height: var(--line-height-03); - color: var(--neutral-90); - } - - small { - color: var(--neutral-70); - font-size: var(--font-size-01); - line-height: var(--line-height-01); - } - } - } - } - } - - .plans-new-table-body:last-of-type { - .plans-new-table-feature-row:last-of-type { - .plans-new-table-feature-td.plans-new-table-highlighted-cell { - border-bottom: var(--border-width-base) solid var(--green-50); - } - } - } - - .plans-new-table-heading-row { - // this means min-height, min-height does not work in table layout - // https://stackoverflow.com/questions/7790222 - height: 64px; - } - - .plans-new-table-heading-text { - padding: var(--spacing-05) var(--spacing-08) var(--spacing-05) - var(--spacing-05); - font-weight: 600; - font-size: var(--font-size-04); - line-height: var(--line-height-03); - vertical-align: bottom; - } - - .plans-new-table-feature-row { - &:nth-child(even) { - background-color: var(--neutral-10); - } - } - - .plans-new-table-section-without-header-row { - &:nth-child(odd):not(.plans-new-table-heading-row) { - background-color: var(--neutral-10); - } - &:nth-child(even):not(.plans-new-table-heading-row) { - background-color: var(--white); - } - } - - .plans-new-table-feature-th { - font-weight: normal; - padding: var(--spacing-05) var(--spacing-08) var(--spacing-05) - var(--spacing-05); - - .plans-new-table-feature-th-content { - line-height: var(--line-height-03); - display: flex; - justify-content: space-between; - align-items: center; - - .plans-new-table-feature-tooltip-icon { - cursor: help; - margin-left: var(--spacing-05); - } - - .tooltip.in { - opacity: 1; - } - - .tooltip-inner { - padding: var(--spacing-04) var(--spacing-06); - max-width: 258px; - width: 258px; - font-family: Lato, sans-serif; - font-size: var(--font-size-02); - text-align: left; - background-color: var(--neutral-90); - border-radius: var(--border-radius-base-new); - } - } - } - - .plans-new-table-feature-td { - padding: var(--spacing-05) var(--spacing-08); - text-align: center; - line-height: var(--line-height-03); - - .green-round-background { - margin-right: 0; - } - } - - .plans-new-table-highlighted-heading { - position: absolute; - left: calc(-1 * var(--border-width-base)); - top: -1 * @highlighted-heading-height; - height: @highlighted-heading-height; - width: calc(100% + (2 * var(--border-width-base))); - border-top-left-radius: var(--border-radius-large-new); - border-top-right-radius: var(--border-radius-large-new); - padding: @highlighted-heading-padding-vertical var(--spacing-04); - font-weight: 600; - text-align: center; - line-height: @highlighted-heading-line-height; - background-color: var(--green-50); - color: var(--white); - font-size: var(--font-size-03); - } - - .plans-new-table-highlighted-cell { - border-right: var(--border-width-base) solid var(--green-50); - border-left: var(--border-width-base) solid var(--green-50); - } - - .ai-assist { - color: var(--neutral-90); - - @media (min-width: @screen-sm-min) { - padding-left: 0 !important; - padding-right: 0 !important; - } - - .ai-assist-heading { - margin-top: var(--spacing-12); - margin-bottom: var(--spacing-12); - text-align: center; - } - - .ai-assist-container { - padding: var(--spacing-10); - border: 4px solid var(--neutral-10); - border-radius: var(--border-radius-large-new); - background: - linear-gradient(270deg, var(--blue-10), #fff) 0.13%, - #fff 63.21%; - - @media (max-width: @screen-sm-max) { - background: #fff; - border-width: 2px; - border-radius: var(--border-radius-medium-new); - border-color: var(--premium-gradient); - padding: var(--spacing-09); - } - - .ai-assist-content { - display: flex; - justify-content: space-between; - gap: var(--spacing-07); - - @media (max-width: @screen-sm-max) { - flex-direction: column; - } - .ai-assist-left { - display: flex; - gap: var(--spacing-07); - flex-direction: column; - max-width: @screen-xs; - - @media (max-width: @screen-sm-max) { - max-width: 100%; - } - - .ai-assist-price { - font-weight: 600; - font-size: var(--font-size-03); - - @media (max-width: @screen-sm-max) { - font-weight: normal; - font-size: var(--font-size-02); - .price { - font-size: var(--font-size-08); - font-weight: 600; - } - } - } - .ai-assist-annually-price { - display: block; - } - .ai-assist-monthly-price { - display: none; - } - .ai-assist-header-switch { - display: none; - } - - .ai-assist-header { - display: flex; - align-items: center; - justify-content: space-between; - - .ai-assist-header-content { - display: flex; - gap: var(--spacing-06); - align-items: center; - - h3 { - font-size: var(--font-size-07); - margin: 0; - padding: 0; - - @media (max-width: @screen-sm-max) { - font-size: var(--font-size-05); - } - } - - img { - @media (max-width: @screen-sm-max) { - width: 24px; - } - } - } - - .ai-assist-header-switch { - align-items: center; - gap: var(--spacing-03); - user-select: none; - - input { - margin: 0; - } - - label { - margin: 0; - font-weight: normal; - } - } - } - } - .ai-assist-right { - display: flex; - align-items: center; - gap: var(--spacing-06); - - .ai-assist-features { - list-style: none; - display: flex; - flex-direction: column; - gap: var(--spacing-04); - min-width: 210px; - margin-bottom: 0; - - .feature-item { - display: flex; - align-items: center; - font-size: var(--font-size-03); - cursor: default; - - .feature-icon { - margin-right: var(--spacing-05); - color: var(--neutral-70); - width: 16px; - height: 16px; - } - - .feature-text { - color: var(--neutral-90); - } - } - - @media (min-width: @screen-sm-min) { - .feature-item-selected { - .feature-text { - color: var(--blue-50); - font-weight: 600; - } - - .feature-icon { - content: url(../../../public/img/check-circle-blue-filled.svg); - } - } - } - } - - .ai-assist-images { - display: flex; - align-items: flex-start; - width: 280px; - justify-content: center; - - @media (max-width: @screen-sm-max) { - display: none; - } - } - } - } - } - - .ai-assist-header-switch { - display: flex !important; - } - - .ai-assist-button-container { - display: flex; - gap: var(--spacing-05); - - &.mobile { - display: none; - margin-top: var(--spacing-06); - flex-direction: column; - } - - @media (max-width: @screen-sm-max) { - &.mobile { - display: flex; - } - &.desktop { - display: none; - } - } - - .btn.monthly { - display: none; - } - } - - .overleaf-assist-screenshot { - height: auto; - max-width: 280px; - max-height: 200px; - border-radius: var(--border-radius-base-new); - display: none; - - &.selected { - display: block; - -webkit-animation: fade-in 1s; - animation: fade-in 1s; - } - } - } - .ai-assist.monthly-period { - .ai-assist-annually-price { - display: none !important; - } - .ai-assist-monthly-price { - display: block !important; - } - .ai-assist-button-container .btn.annual { - display: none !important; - } - .ai-assist-button-container .btn.monthly { - display: block !important; - } - } - - .plans-new-organizations { - padding: var(--spacing-13) var(--spacing-08); - - .plans-new-organizations-text { - text-align: center; - font-size: var(--font-size-05); - line-height: var(--line-height-04); - margin-bottom: var(--spacing-00); - } - - .plans-new-organizations-logo { - margin-top: var(--spacing-09); - display: flex; - justify-content: space-around; - align-items: center; - - @media (max-width: @screen-md-max) { - flex-wrap: wrap; - gap: 30px; - } - } - } - - .plans-card-container-mobile { - width: 100%; - display: flex; - flex-direction: column; - gap: var(--spacing-06); - - .mt-spacing-06 { - margin-top: var(--spacing-06); - } - - .highlighted-plans-card { - border: 2px solid var(--green-50) !important; - } - - .plans-card-mobile { - padding: var(--spacing-09); - border: 2px solid var(--neutral-20); - width: 100%; // might need max-width - border-radius: 8px; - display: flex; - flex-direction: column; - - .plans-card-title-mobile { - color: var(--neutral-90); - font-size: var(--font-size-05); // 20px - font-weight: 600; - line-height: var(--line-height-04); - } - - .plans-card-icon-container-mobile { - margin-top: var(--spacing-04); - .plans-card-icon { - font-size: var(--font-size-09); - color: var(--neutral-90); - } - } - - s { - padding: var(--spacing-04) 0 0 0; - color: var(--neutral-60); - font-size: var(--font-size-04); // 18px - font-weight: 600; - line-height: var(--line-height-05); - margin-bottom: var(--spacing-04); - } - - .plans-card-price-container-mobile { - display: flex; - align-items: baseline; - - .light-gray-text:has(.billed-annually-disclaimer) { - align-self: center; - } - } - - .group-plans-card-price-container-mobile { - display: flex; - align-items: center; - } - - .plans-card-price-mobile { - color: var(--neutral-90); - font-size: var(--font-size-08); // 36px - font-weight: 600; - line-height: var(--line-height-07); - margin-right: var(--spacing-03); - } - - .light-gray-text { - color: var(--neutral-70); - font-size: var(--font-size-02); // 14px - line-height: var(--line-height-02); - } - - .plans-card-description-mobile { - .green-round-background { - width: 20px; - height: 20px; - } - - .plans-card-description-list-mobile { - list-style-type: none; - padding-left: 0; - margin-bottom: unset; - li { - display: flex; - margin-top: var(--spacing-05); - } - } - - .plans-card-cta-container { - margin-top: var(--spacing-08); - width: 100%; - - .btn-block + .btn-block { - margin-top: var(--spacing-04); - } - } - } - } - } - - .plans-new-group-tab-card-container { - margin-top: var(--spacing-09); - } - - .plans-features-table-section-container-mobile { - margin-top: var(--spacing-13); - .plans-features-section-heading-mobile { - font-size: var(--font-size-06); - font-weight: 600; - line-height: var(--line-height-05); - color: var(--neutral-90); - text-align: center; - margin-bottom: var(--spacing-08); - } - - .plans-features-table-mobile { - width: 100%; - - .plans-features-table-sticky-header { - position: sticky; - top: 0; - } - - .plans-features-table-header { - margin-bottom: var(--space-08); - } - - .plans-features-table-header-container-mobile { - margin: var(--spacing-08) auto; - border-bottom: unset; - width: 100%; - max-width: 544px; - - .plans-features-table-header-item-mobile { - width: 33%; - min-width: 114px; - padding: unset; - - .plans-features-table-header-item-content-mobile { - padding: var(--spacing-04); - text-align: center; - background-color: var(--neutral-10); - } - - .plans-group-features-table-header-item-content-mobile { - padding: var(--spacing-04); - text-align: center; - background-color: var(--neutral-10); - height: 64px; - display: flex; - justify-content: center; - align-items: center; - } - - .plans-features-table-header-item-title-mobile { - color: var(--neutral-90); - line-height: var(--line-height-03); - font-weight: 600; - font-size: var(--font-size-03); - } - - .plans-features-table-header-item-price-mobile { - font-weight: 400; - color: var(--neutral-70); - line-height: var(--line-height-01); - font-size: var(--spacing-05); - } - } - - .highlighted-styles { - background-color: var(--neutral-80); - - .plans-features-table-header-item-title-mobile, - .plans-features-table-header-item-price-mobile { - color: var(--white); - } - } - - .plans-features-table-header-item-content-mobile.highlighted, - .plans-group-features-table-header-item-content-mobile.highlighted { - .highlighted-styles; - } - } - - tr.plans-features-table-row-for-margin { - height: var(--spacing-08); - } - - .plans-features-table-body-container-mobile { - .plans-features-table-row-heading-mobile { - font-weight: 600; - text-align: center; - line-height: var(--line-height-03); - .plans-features-table-row-section-heading-content-mobile { - padding-top: var(--spacing-08); - padding-bottom: var(--spacing-05); - font-size: var(--font-size-04); - color: var(--neutral-90); - } - } - - // .plans-features-table-row-title-mobile and .plans-features-table-row-mobile are combined together to make one row visually, so we are using factors of 4 to alternatively color their backgrounds. - .plans-features-table-row-title-mobile { - &.plans-features-table-row-title-mobile-without-heading { - &:nth-child(4n - 3) { - background-color: var(--neutral-10); - } - - &:nth-child(4n - 1) { - background-color: var(--white); - } - } - - &:nth-child(4n - 2) { - background-color: var(--neutral-10); - } - - &:nth-child(4n) { - background-color: var(--white); - } - - .plans-features-table-row-title-content-mobile { - display: flex; - justify-content: center; - align-items: center; - padding-top: var(--spacing-06); - font-weight: 600; - line-height: var(--line-height-03); - .plans-features-table-row-title-accordion { - display: flex; - justify-content: center; - flex-direction: column; - align-items: center; - text-align: center; - padding: 0 var(--spacing-04); - .plans-features-table-row-title-accordion-header { - font-size: var(--font-size-03); - font-weight: 600; - line-height: var(--line-height-03); - display: flex; - justify-content: center; - background-color: unset; - border: unset; - .plans-features-table-row-title-accordion-icon { - display: flex; - align-items: center; - transition: transform 0.35s ease; - margin-left: var(--spacing-02); - } - - &:not(.collapsed) { - .plans-features-table-row-title-accordion-icon { - transform: rotate(180deg); - transition: transform 0.35s ease; - } - } - } - .plans-features-table-row-title-accordion-body { - font-size: var(--font-size-01); - line-height: var(--line-height-01); - font-weight: 400; - } - } - } - } - - .plans-features-table-row-mobile { - &.plans-features-table-row-mobile-without-heading { - &:nth-child(4n - 2) { - background-color: var(--neutral-10); - } - - &:nth-child(4n) { - background-color: var(--white); - } - } - - &:nth-child(4n - 3) { - background-color: var(--white); - } - - &:nth-child(4n - 1) { - background-color: var(--neutral-10); - } - - .plans-features-table-cell-content-mobile { - text-align: center; - padding-top: var(--spacing-05); - padding-bottom: var(--spacing-06); - } - } - } - } - } - - .plans-price-disclaimer { - font-size: var(--font-size-01); - line-height: var(--line-height-01); - margin-top: var(--spacing-08); - text-align: center; - - &:last-child { - margin-bottom: var(--spacing-11); - } - - &:not(:last-child) { - margin-bottom: var(--spacing-08); - } - - .plans-price-disclaimer-icons { - display: flex; - justify-content: center; - gap: var(--spacing-04); - } - } - - .only-show-for-specific-plan-type { - display: none; - } - &[data-ol-current-plan-type='group'] { - .show-for-plan-type-group { - display: block; - } - } - &[data-ol-current-plan-type='individual'] { - .show-for-plan-type-individual { - display: block; - } - } - &[data-ol-current-plan-type='student'] { - .show-for-plan-type-student { - display: block; - } - } - - .only-show-for-specific-plan-period { - display: none; - } - &[data-ol-current-plan-period='annual'] { - .show-for-plan-period-annual { - display: block; - } - } - &[data-ol-current-plan-period='monthly'] { - .show-for-plan-period-monthly { - display: block; - } - } -} - -.plans-overleaf-common-request { - color: var(--neutral-90); - display: flex; - align-items: center; - justify-content: center; - margin: var(--spacing-04) var(--spacing-08); - text-align: center; - gap: var(--spacing-06); - - @media (max-width: @screen-xs-max) { - flex-direction: column; - margin: 0; - } - - a { - font-size: var(--font-size-02); - line-height: var(--line-height-02); - } -} - -.plans-faq { - .faq-heading-container { - text-align: center; - margin-bottom: var(--spacing-10); - - @media (max-width: @screen-xs-max) { - text-align: unset; - } - } - - .plans-faq-support { - margin-top: var(--spacing-06); - margin-bottom: var(--spacing-06); - display: flex; - flex-direction: column; - align-items: center; - gap: var(--spacing-04); - - span { - line-height: var(--line-height-03); - font-size: var(--font-size-04); - } - - button { - font-family: 'DM Mono', monospace; - font-weight: 500; - text-decoration: none; - color: var(--green-50); - line-height: var(--line-height-03); - font-size: var(--font-size-04); - background-color: var(--white); - border: unset; - display: flex; - align-items: center; - } - } -} - -.plans-new-design.plans-interstitial-new-design { - padding-top: var(--header-height); - padding-bottom: var(--spacing-09); - - .plans-interstitial-new-content { - display: flex; - flex-direction: column; - align-items: center; - } - - .plans-new-table { - th, - td { - width: @table-5-column-width; - } - } -} diff --git a/services/web/frontend/stylesheets/app/primary-email-check.less b/services/web/frontend/stylesheets/app/primary-email-check.less deleted file mode 100644 index 2a1a766fd4..0000000000 --- a/services/web/frontend/stylesheets/app/primary-email-check.less +++ /dev/null @@ -1,39 +0,0 @@ -.primary-email-check-container { - max-width: 400px !important; -} - -.primary-email-check-card { - padding: 24px; - display: flex; - flex-direction: column; - gap: 12px; - - .primary-email-confirm-button { - margin-top: 12px; - } - - .primary-email-change-button { - margin-bottom: 12px; - } - - p { - margin-bottom: 0; - } -} - -.primary-email-check-header { - margin-top: 12px; - margin-bottom: 0; -} - -.primary-email-check-form { - padding: 0 !important; - display: flex; - flex-direction: column; - gap: 12px; -} - -.primary-email-check-logo { - width: 130px; - margin: 0 auto; -} diff --git a/services/web/frontend/stylesheets/app/register.less b/services/web/frontend/stylesheets/app/register.less deleted file mode 100644 index 61c7b79102..0000000000 --- a/services/web/frontend/stylesheets/app/register.less +++ /dev/null @@ -1,31 +0,0 @@ -.registration_message { - text-align: center; - padding-bottom: 20px; -} -// for focus-registration and focus-login split test variant -.registration_logo { - width: 130px; - padding: 8px 0; -} - -.website-redesign { - .login-register-header-focus { - padding-top: unset; - } -} - -.login-register-header-heading-focus { - color: @neutral-90; - margin-bottom: 0; -} - -.website-redesign { - .login-register-form-focus { - padding: @line-height-computed 0 0 0; - border-bottom: unset; - border-bottom: solid 1px @hr-border; - &:last-child { - border-bottom-width: 0; - } - } -} diff --git a/services/web/frontend/stylesheets/app/website-redesign.less b/services/web/frontend/stylesheets/app/website-redesign.less deleted file mode 100644 index 6c5bf9b582..0000000000 --- a/services/web/frontend/stylesheets/app/website-redesign.less +++ /dev/null @@ -1,1186 +0,0 @@ -@cta-card-bg-color: var(--dark-jungle-green); - -.website-redesign-overflow-unset { - overflow: unset !important; -} - -.website-redesign { - // hero section of features, enterprises, and universities will have an image that will overflow the page - overflow-x: hidden; - - p, - div, - h1, - h2, - h3, - h4, - a, - strong { - font-family: 'Noto Sans', sans-serif; - } - - h1 { - font-weight: 600; - .heading-2xl(); - - @media (max-width: @screen-sm-max) { - .heading-xl(); - } - } - - h2 { - font-weight: 600; - margin-top: 0; - .heading-xl(); - - @media (max-width: @screen-sm-max) { - .heading-lg(); - } - } - - h3 { - font-weight: 600; - } - - h1, - h2, - h3 { - > span.eyebrow-text { - display: block; - margin-bottom: @margin-xs; - } - } - - .eyebrow-text { - .mono-text; - } - - // override .btn default style - .btn { - font-weight: 600; - } - - .img-rounded { - // TODO: design specifies 'border-radius-large' which is 5px, but uses 16px - border-radius: 16px; - } - - .plans-cards { - @media (min-width: @screen-md-min) { - display: flex; /* equal heights */ - flex-wrap: wrap; - } - - .plans-card-container { - min-height: 348px; - - @media (max-width: @screen-sm-max) { - margin-bottom: 16px; - min-height: unset; - } - } - - .plans-card { - border-radius: 8px; - padding: 0; - height: 100%; - - .plans-card-inner { - padding: 36px; - height: 100%; - display: flex; - flex-direction: column; - font-size: 16px; - - .plans-card-inner-title { - font-size: 20px; - font-weight: 600; - margin-top: 0; - } - - ul { - list-style-type: none; - padding: 0; - margin: 0; - - li { - margin-bottom: 8px; - } - } - - .plans-card-inner-footer { - margin-top: auto; - display: flex; - flex-direction: column; - gap: 12px; - - @media (max-width: @screen-sm-max) { - margin-top: 16px; - } - } - } - - &.grey-border { - border: 2px solid var(--neutral-20); - } - - &.blue-border { - border: solid 2px var(--sapphire-blue); - border-radius: 8px; - - .plans-card-inner-title { - color: var(--sapphire-blue); - } - } - } - } - - .plans-bottom-text { - font-size: 1.125rem; - } - - .info-cards { - @media (min-width: @screen-md-min) { - display: flex; /* equal heights */ - flex-wrap: wrap; - } - - .info-card-container { - margin-bottom: 16px; - - .info-card { - border-radius: 8px; - height: 100%; - box-shadow: - 0px 2px 4px 0px #1e253014, - 0px 4px 12px 0px #1e25301f; - border-top: 8px solid var(--sapphire-blue); - padding: 32px 40px 32px 40px; - - &.info-card-big-text { - h3 { - font-size: 1.5rem; - line-height: 1.333; - } - - p { - font-size: 1.125rem; - line-height: 1.333; - } - } - - .material-symbols { - color: var(--sapphire-blue); - } - } - } - } - - .security-heading-section { - @media (max-width: @screen-sm-max) { - p { - text-align: left; - } - - h2 { - width: 100%; - text-align: left; - } - } - - .heading-and-stickers-container { - display: flex; - justify-content: center; - - .lock-sticker { - width: 70px; - position: absolute; - top: -95px; - right: -50px; - - @media (max-width: @screen-lg) { - right: -105px; - } - - @media (max-width: @screen-sm-max) { - display: none; - } - } - - .arrow-sticker { - width: 140px; - position: absolute; - top: -50px; - right: -15px; - - @media (max-width: @screen-lg) { - right: -70px; - } - @media (max-width: @screen-sm-max) { - display: none; - } - } - } - } - - .customer-story-card-title { - margin-top: 25px; - margin-bottom: 12.5px; - font-size: 1.5rem; - font-weight: 600; - - a { - display: flex; - justify-content: space-between; - color: var(--neutral-90); - i { - font-size: 1.5rem; - } - } - } - - .customer-story-content { - .table-of-contents-section { - padding-right: 64px; - @media (max-width: @screen-sm-max) { - display: none; - } - - .table-of-contents { - border-top: 1px solid var(--neutral-30); - border-bottom: 1px solid var(--neutral-30); - padding: 32px 0; - - .heading { - font-size: 1.125rem; - font-weight: 600; - line-height: 1.333; - color: var(--green-60); - } - li { - list-style: none; - padding-top: 16px; - font-weight: 500; - a { - text-decoration: none; - color: var(--neutral-70); - } - } - } - } - - .story-details-section { - h3 { - font-size: 1.875rem; - line-height: 1.333; - margin-top: 0; - } - - .at-glance-section { - padding-top: 24px; - p { - margin-top: 16px; - } - } - - p { - font-size: 1.125rem; - } - - .border-r-16 { - border-radius: 16px; - } - - .introduction-image-caption { - padding-top: 16px; - } - - .stats-card { - display: flex; - flex-direction: row; - padding: 32px; - border-radius: 16px; - background: var(--dark-jungle-green); - color: var(--white); - @media (max-width: @screen-sm-max) { - flex-direction: column; - } - - .stats { - h3 { - font-size: 2rem; - font-weight: 600; - } - - p { - font-weight: 600; - height: 50px; - } - } - } - } - - .customer-quote { - border-left: 2px solid var(--green-60); - padding-left: 16px; - - blockquote { - color: var(--neutral-90); - font-size: 1.6rem; - line-height: 1.333; - } - - p { - margin-top: 1rem; - font-size: 1.125rem; - } - } - } - - .features-card { - display: flex; /* equal heights */ - flex-wrap: wrap; - align-items: center; - - .features-card-media, - .features-card-media-right { - padding-top: 24px; - - img.img-responsive { - width: 100%; - } - - video { - box-shadow: - 0px 4px 6px 0px rgba(30, 37, 48, 0.12), - 0px 8px 16px 0px rgba(30, 37, 48, 0.12); - max-height: 100%; - width: auto; - width: 100%; - } - - @media (max-width: @screen-sm-max) { - margin-bottom: 50px; - } - } - - @media (min-width: @screen-md-min) { - .features-card-media-right { - padding-left: 64px; - } - - .features-card-media { - padding-right: 64px; - } - } - - .features-card-description, - .features-card-description-list { - padding-top: 16px; - - p { - margin-bottom: 16px; - font-size: 1.125rem; - line-height: 1.333; - - @media (max-width: @screen-sm-max) { - font-size: 1rem; - line-height: 1.375; - } - } - - a.green-link { - margin-top: 4px; - display: block; - } - } - - .features-card-description { - h3 { - font-size: 1.5rem; - line-height: 1.4; - margin-bottom: 8px; - } - } - - .features-card-description-list { - h3 { - font-size: 1.875rem; - line-height: 1.5; - } - - ul.list-simple-text, - ul.list-heading-text { - list-style-type: none; - padding: 0; - margin: 0; - - li { - margin-bottom: 12px; - font-size: 1.125rem; - line-height: 1.333; - display: flex; - - .label-premium { - margin-left: 0; - font-family: 'Noto Sans', sans-serif; - } - - @media (max-width: @screen-sm-max) { - font-size: 1rem; - line-height: 1.375; - } - } - } - - ul.list-heading-text { - li { - h4 { - margin-top: 0; - margin-bottom: 8px; - font-size: 20px; - font-weight: 600; - } - } - } - } - } - - .features-card-hero { - display: flex; /* equal heights */ - flex-wrap: wrap; - align-items: center; - position: relative; - height: 655px; - padding-top: @line-height-computed * 2; - - @media (max-width: @screen-sm-max) { - height: unset; - padding-top: 0; - } - - .features-card-description { - display: flex; - flex-direction: column; - justify-content: center; - - h1 { - &.features-card-hero-smaller-title { - @media (min-width: @screen-lg-min) { - // 3rem is the default, this is a workaround for big screen - // since 6-width column on md screen size will wrap the text in three lines - font-size: 2.8rem; - } - } - } - - p { - font-size: 1.25rem; - width: 90%; - - @media (max-width: @screen-sm-max) { - font-size: 1.125rem; - line-height: 1.33; - width: unset; - } - } - } - - .features-card-image { - position: absolute; - // on wide screen, image will be fixed without any variable width translation - transform: translateX(600px); - top: 100px; - width: 720px; - height: auto; - padding: 0px 15px; - - // starting from 1500px, image will have a variable translation that depends on screen width - // this will make image "fixed" on a specific point on the screen - @media (max-width: 1500px) { - transform: translateX(calc(50vw - 121px)); - } - - @media (max-width: 1400px) { - width: 650px; - transform: translateX(calc(50vw - 52px)); - } - - // bootstrap layout changes on 1200px (@screen-lg), add a specific - // case for this exact width - @media (min-width: 1200px) and (max-width: 1200px) { - width: 600px; - transform: translateX(calc(50vw)); - } - - @media (max-width: 1199px) { - width: 600px; - transform: translateX(calc(50vw - 106px)); - } - - @media (max-width: 1100px) { - width: 550px; - transform: translateX(calc(50vw - 55px)); - } - - // 991px - @media (max-width: @screen-sm-max) { - position: relative; - transform: none; - top: 0; - width: 100%; - margin-bottom: 50px; - } - - img.img-responsive { - width: 100%; - } - } - - .sticky-tags { - position: absolute; - z-index: 2; - height: 160px; - bottom: -105px; - right: 55px; - - @media (max-width: 1400px) { - height: 150px; - bottom: -103px; - right: 47px; - } - - @media (max-width: 1200px) { - height: 130px; - bottom: -87px; - } - - @media (max-width: 1100px) { - height: 120px; - bottom: -81px; - } - - // 991px - @media (max-width: @screen-sm-max) { - height: 130px; - bottom: -75px; - right: 70px; - } - - // 767px - @media (max-width: @screen-xs-max) { - height: 24%; - bottom: -10vw; // scale with width - right: 9.5vw; // scale with width - } - } - } - - .organization-logos-container { - display: flex; - justify-content: space-around; - align-items: center; - - @media (max-width: @screen-md-max) { - flex-wrap: wrap; - gap: 30px; - } - - .organization-logo { - object-fit: contain; - max-height: 62px; - - &.samsung-logo { - max-height: 110px; - height: 110px; - } - - @media (max-width: @screen-md-max) { - max-height: 40px; - flex-basis: 34%; - } - } - } - - .template-cards { - .template-card { - > img.img-responsive { - width: 100%; - border-radius: 8px; - } - - .template-card-title { - font-size: 24px; - margin-top: 16px; - margin-bottom: 8px; - - a { - width: 100%; - font-weight: 600; - color: var(--neutral-90); - display: inline-flex; - justify-content: space-between; - text-decoration: none; - - &:hover { - text-decoration: underline; - } - } - - .material-symbols { - vertical-align: middle; - text-decoration: none; - } - } - - .template-card-text { - font-size: 16px; - } - - &:not(:last-of-type) { - @media (max-width: @screen-sm-max) { - margin-bottom: 40px; - } - } - } - } - - .lime-color-text { - color: var(--malachite); - } - - .cta-card-individual-customer { - display: flex; - justify-content: space-between; - padding: 64px; - background-image: linear-gradient( - to right, - rgba(0, 0, 0, 0.4) 0%, - @cta-card-bg-color 20%, - @cta-card-bg-color 100% - ), - url('../../../public/img/website-redesign/overleaf-pattern-purple.png'); - background-size: cover; - color: var(--white); - border-radius: 8px; - - @media (max-width: @screen-sm-max) { - padding: 48px 24px; - } - - h2 { - font-size: 1.875rem; - color: var(--white); - } - p { - font-size: 1.125rem; - } - - .btn-container { - padding-top: 10px; - display: flex; - justify-content: center; - align-items: flex-start; - } - } - - .paragraph-line-height { - line-height: 1.333; // 24px - } - - .cta-card { - display: flex; - flex-direction: column; - align-items: center; - padding: 64px; - color: var(--white); - background-image: linear-gradient( - to right, - rgba(0, 0, 0, 0.4) 0%, - @cta-card-bg-color 25%, - @cta-card-bg-color 75%, - rgba(0, 0, 0, 0.4) 100% - ), - url('../../../public/img/website-redesign/overleaf-pattern-purple.png'); - background-size: cover; - border-radius: 8px; - - @media (max-width: @screen-sm-max) { - padding: 48px 24px; - } - - .cta-card-title { - font-size: 3.25rem; // 52px - margin-bottom: 8px; - line-height: 1.3; - - &.title-mono { - > span { - // override Noto Sans - .dm-mono; - } - } - - span.purple-color { - color: #939aff; - font-weight: 400; - } - - span.lime-color { - font-weight: 500; - color: var(--malachite); - } - - @media (max-width: @screen-sm-max) { - font-size: 2.25rem; // 36px - } - } - - .cta-card-text { - font-size: 1.125rem; - line-height: 1.333; - margin: 8px 0; - text-align: center; - } - - .cta-card-quote { - font-size: 1.875rem; // 30px - font-weight: 600; - line-height: 2.5rem; // 40px - letter-spacing: 0em; - text-align: center; - } - } - - .quote-card { - display: flex; - flex-direction: column; - align-items: center; - padding: 32px; - background: var(--dark-jungle-green); - color: white; - border-radius: 16px; - text-align: center; - - blockquote { - font-size: 1.875rem; // 30px - font-weight: 600; - line-height: 1.26; - - @media (max-width: @screen-sm-max) { - font-size: 1.5rem; // 24px - line-height: 1.333; - } - } - - .quote-card-img { - margin-top: 32px; - margin-bottom: 16px; - } - - .quote-card-link { - color: var(--green-30); - } - - @media (max-width: @screen-sm-max) { - padding: 56px 24px 56px 24px; - } - } - - .integrations-card { - display: flex; /* for center align */ - flex-wrap: wrap; - align-items: center; - - .integrations-icons { - img { - width: 6rem; // 96px - height: 6rem; // 96px - } - - .first-row, - .second-row { - display: flex; - } - - .first-row { - justify-content: space-between; - } - - .second-row { - margin-top: 40px; - justify-content: space-evenly; - } - } - } - - .text-with-bg { - .dm-mono; - padding: 0 @padding-sm; - border-radius: 10px; - margin-top: 5px; - - // will make all spans content inline while avoiding overflowing the viewport in mobile - // https://developer.mozilla.org/en-US/docs/Web/CSS/display#flow-root - // https://css-tricks.com/display-flow-root/ - display: inline flow-root; - - &.tangerine-bg { - background-color: var(--vivid-tangerine); - } - - &.purple-bg { - background-color: var(--ceil); - } - - &.yellow-bg { - background-color: var(--caramel); - } - - &.green-bg { - background-color: var(--green-30); - } - } - - .security-info { - .security-info-first-row { - margin-bottom: 32px; - @media (max-width: @screen-sm-max) { - margin-bottom: 0; - } - } - - .security-info-item { - @media (max-width: @screen-sm-max) { - margin-bottom: 16px; - } - } - } - - .resources { - @media (min-width: @screen-md-min) { - display: flex; /* equal heights */ - flex-wrap: wrap; - } - .resources-card { - display: flex; - flex-wrap: wrap; - flex-direction: column; - margin-bottom: 48px; - align-content: flex-start; - - @media (max-width: @screen-sm-max) { - margin-bottom: 16px; - } - - img { - width: 56px; - } - - h3 { - width: 100%; - } - - a { - margin-top: auto; - } - } - } - - .centered-block { - @media (min-width: @screen-md-min) { - text-align: center; - } - } - - .heading-section-md-align-left { - @media (max-width: @screen-md-min) { - display: flex; - flex-direction: column; - align-items: baseline; - - h2 { - text-align: left; - } - p { - text-align: left; - } - } - } - - .dm-mono { - font-family: 'DM Mono', monospace; - // We're using the "ss05" (stylistic set 5) version of the DM Mono, by setting the `font-feature-settings` rule - // https://developer.mozilla.org/en-US/docs/Web/CSS/font-feature-settings - // We use the ss05 specifically to remove the "squiggle" below the f letter. - // You can try removing the `font-feature-settings` rule and check what happens to the letter "f", - // as it's quite hard to describe it with sentences alone - font-feature-settings: 'ss05'; - } - - .mono-text { - .dm-mono; - color: var(--green-60); - font-size: 1.125rem; - font-weight: 500; - line-height: 1.5rem; - margin: 0; - } - - .customer-stories-hero-heading { - @media (max-width: @screen-sm-max) { - font-size: 2.25rem; - line-height: 1.333; - } - } - - .customer-stories-hero-text { - font-size: 1.25rem; - } - - .customer-stories-logos-text { - font-size: 1.125rem; - } - - .link-with-arrow { - .dm-mono; - font-size: var(--font-size-04); - line-height: var(--line-height-03); - font-weight: 500; - color: var(--green-50); - - .material-symbols { - vertical-align: middle; - padding-bottom: 3px; - font-size: 24px; - line-height: inherit; - margin-left: var(--spacing-02); - } - - &:hover { - color: var(--green-60); - } - } - - .link-monospace { - .dm-mono; - font-weight: 500; - color: var(--green-50); - &:hover { - color: var(--green-60); - } - } - - .green-link { - .link-with-arrow; - // TODO: replace .green-link uses with .link-with-arrow - } - - .inline-green-link { - color: var(--green-50); - padding: 0; - text-decoration: underline; - // text-decoration-;skip-ink is for letter with descender (like 'g' and 'y') - // this will force underline to not skip the descender - text-decoration-skip-ink: none; - - &:hover { - color: var(--green-60); - } - - &:focus { - &:extend(.input-focus-style); - } - } - - .btn-blue { - background: var(--sapphire-blue); - color: var(--white); - - &:hover { - background: var(--sapphire-blue-dark); - } - } - - .btn-primary { - &:not([disabled]) { - background: var(--green-50); - color: var(--white); - - &:hover { - background: var(--green-60); - } - } - } - - a { - &:focus, - &:focus-visible { - outline: 0; - } - - &:focus-visible { - .box-shadow-button-input(); - } - - .material-symbols { - vertical-align: middle; - margin-left: var(--spacing-02); - padding-bottom: 3px; - } - - &.link-lg { - font-size: var(--font-size-04); - line-height: var(--line-height-03); - - i { - font-size: 24px; - line-height: inherit; - } - } - } - - .round-background { - border-radius: 50%; - font-size: 1.5rem; - top: 4px; - vertical-align: middle; - margin-right: 8px; - width: 24px; - height: 24px; - } - - .green-round-background { - .round-background; - background: var(--green-30); - } - - .blue-round-background { - .round-background; - background: var(--blue-10); - color: var(--blue-40); - } - - // most of these are here to replace rules from core/type.less - blockquote { - border-left: none; - font-size: 1.875rem; - line-height: 1.333; - font-weight: 600; - quotes: '\201C' '\201D'; // override default quotes - padding: unset; - margin: unset; - font-family: 'Noto Sans', sans-serif; - - &::after { - visibility: visible; - display: inline; - margin-left: 1px; // it's too tight to the text otherwise - } - - &::before { - color: inherit; - margin-right: 0; - vertical-align: 0; - } - - &::after, - &::before { - font-size: 1.875rem; - line-height: 1.333; - } - - @media (max-width: @screen-sm-max) { - font-size: 1.5rem; - line-height: 1.333; - - &::after, - &::before { - visibility: visible; - font-size: 1.5rem; - line-height: 1.333; - } - } - } - - .circle-img { - height: 64px; - max-width: 64px; - } - - .responsive-button-container { - display: flex; - margin-top: 24px; - gap: 16px; - - &.centered-buttons { - justify-content: center; - } - - &.align-left-button-sm { - @media (max-width: @screen-sm-max) { - justify-content: start; - } - } - - @media (max-width: @screen-xs-max) { - width: 100%; - flex-direction: column; - } - } - - .label-premium { - height: 20px; // override default height - } - - .label-premium-block-md { - @media (max-width: @screen-md-max) { - display: block; - width: fit-content; - margin: 10px 0px 0px 0px; - } - } - - .header-description { - p { - font-size: 1.25rem; - line-height: 1.4; - margin-bottom: 0; - - @media (max-width: @screen-sm-max) { - font-size: 1.125rem; - line-height: 1.33; - } - } - } - - .editor-pdf-video { - display: flex; - align-items: center; - justify-content: center; - height: 585px; - padding: 0px 15px; - - @media (max-width: @screen-sm-max) { - height: auto; - } - - video { - box-shadow: 0px 60px 25px -15px rgba(16, 24, 40, 0.2); - max-height: 100%; - width: auto; - - @media (max-width: @screen-sm-max) { - width: 100%; - } - } - } - - .overleaf-sticker { - float: right; - - @media (max-width: @screen-sm-max) { - width: 74px; // 70% of 106px - } - } -} diff --git a/services/web/frontend/stylesheets/bootstrap-5/base/layout.scss b/services/web/frontend/stylesheets/bootstrap-5/base/layout.scss index d27fb59ad7..89feb11880 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/base/layout.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/base/layout.scss @@ -90,3 +90,8 @@ hr { .full-height { height: 100%; } + +.table-fixed { + table-layout: fixed; + word-wrap: break-word; +} diff --git a/services/web/frontend/stylesheets/bootstrap-5/base/links.scss b/services/web/frontend/stylesheets/bootstrap-5/base/links.scss index dc20dea1d3..f4bcbc6408 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/base/links.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/base/links.scss @@ -30,10 +30,10 @@ a { @include font-mono; font-weight: 500; - color: var(--green-50); + color: var(--link-web); &:hover { - color: var(--green-60); + color: var(--link-web-hover); } } diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/all.scss b/services/web/frontend/stylesheets/bootstrap-5/components/all.scss index 639f6b4ff5..a0237b47f0 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/components/all.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/components/all.scss @@ -46,3 +46,4 @@ @import 'upgrade-prompt'; @import 'integrations-panel'; @import 'group-members'; +@import 'upgrade-benefits'; diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/card.scss b/services/web/frontend/stylesheets/bootstrap-5/components/card.scss index b99c107083..777ec46c79 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/components/card.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/components/card.scss @@ -107,7 +107,8 @@ color: var(--white); text-align: center; background-size: cover; - background-image: linear-gradient( + background-image: + linear-gradient( to right, rgba(0 0 0 / 40%) 0%, var(--dark-jungle-green) 25%, @@ -142,7 +143,8 @@ border-radius: var(--border-radius-medium); color: var(--white); background-size: cover; - background-image: linear-gradient( + background-image: + linear-gradient( to right, rgba(0 0 0 / 40%) 0%, var(--dark-jungle-green) 20%, diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/footer.scss b/services/web/frontend/stylesheets/bootstrap-5/components/footer.scss index cd92669668..48e4a25261 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/components/footer.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/components/footer.scss @@ -354,12 +354,12 @@ footer.site-footer { } .fat-footer-sections { - grid-template-columns: repeat(6, 1fr); + grid-template-columns: repeat(7, 1fr); grid-template-rows: auto; } .footer-section:last-of-type { - grid-column: 6; + grid-column: 7; } } } diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/nav.scss b/services/web/frontend/stylesheets/bootstrap-5/components/nav.scss index 5d28341cf5..dd0600ed15 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/components/nav.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/components/nav.scss @@ -8,7 +8,10 @@ --navbar-padding-h: var(--spacing-05); --navbar-padding: 0 var(--navbar-padding-h); --navbar-brand-width: 130px; - --navbar-brand-image-url: url('../../../../public/img/ol-brand/overleaf-white.svg'); + --navbar-brand-image-url: var( + --navbar-brand-image-default-url, + url('../../../../public/img/ol-brand/overleaf-white.svg') + ); // Title, when used instead of a logo --navbar-title-font-size: var(--font-size-05); diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/navbar.scss b/services/web/frontend/stylesheets/bootstrap-5/components/navbar.scss index 3b984bb6f3..3ec45aef2f 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/components/navbar.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/components/navbar.scss @@ -1,3 +1,8 @@ +.no-scroll { + height: 100vh; + overflow: hidden; +} + // Default navbar .navbar-default { background-color: var(--navbar-bg); @@ -216,7 +221,10 @@ .website-redesign .navbar-default { --navbar-title-color: var(--content-primary); --navbar-title-color-hover: var(--content-secondary); - --navbar-brand-image-url: url('../../../../public/img/ol-brand/overleaf-black.svg'); + --navbar-brand-image-url: var( + --navbar-brand-image-redesign-url, + url('../../../../public/img/ol-brand/overleaf-black.svg') + ); --navbar-subdued-color: var(--content-primary); --navbar-subdued-hover-bg: var(--bg-dark-primary); --navbar-subdued-hover-color: var(--content-primary-dark); diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/notifications.scss b/services/web/frontend/stylesheets/bootstrap-5/components/notifications.scss index ece1a465a4..e9f35c940d 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/components/notifications.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/components/notifications.scss @@ -70,6 +70,12 @@ padding: 18px $spacing-06 0 0; } + .notification-icon.notification-icon-center { + padding-top: 0; + display: flex; + align-items: center; + } + .notification-content-and-cta { // shared container to align cta with text on smaller screens display: flex; @@ -121,6 +127,7 @@ cursor: pointer; background: transparent; border: 0; + color: var(--content-primary); &:hover, &:focus { diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/upgrade-benefits.scss b/services/web/frontend/stylesheets/bootstrap-5/components/upgrade-benefits.scss new file mode 100644 index 0000000000..d9702c3f64 --- /dev/null +++ b/services/web/frontend/stylesheets/bootstrap-5/components/upgrade-benefits.scss @@ -0,0 +1,3 @@ +.upgrade-benefits li { + display: flex; +} diff --git a/services/web/frontend/stylesheets/bootstrap-5/foundations/colors.scss b/services/web/frontend/stylesheets/bootstrap-5/foundations/colors.scss index ac196b900e..8e5e9e8a78 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/foundations/colors.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/foundations/colors.scss @@ -90,8 +90,8 @@ $border-disabled: $neutral-20; $border-active: $blue-50; $border-danger: $red-50; $border-divider: $neutral-20; -$link-web: $green-50; -$link-web-hover: $green-60; +$link-web: $green-60; +$link-web-hover: $green-70; $link-web-visited: $green-60; $link-ui: $blue-50; $link-ui-hover: $blue-60; @@ -203,9 +203,9 @@ $link-ui-visited-dark: $blue-40; --border-danger: var(--red-50); --border-divider: var(--neutral-20); --border-dark-divider: var(--neutral-70); - --link-web: var(--green-50); - --link-web-hover: var(--green-60); - --link-web-visited: var(--green-50); + --link-web: var(--green-60); + --link-web-hover: var(--green-70); + --link-web-visited: var(--green-60); --link-ui: var(--blue-50); --link-ui-hover: var(--blue-60); --link-ui-visited: var(--blue-60); diff --git a/services/web/frontend/stylesheets/bootstrap-5/foundations/spacing.scss b/services/web/frontend/stylesheets/bootstrap-5/foundations/spacing.scss index 2609c99b6d..6170be4cc9 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/foundations/spacing.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/foundations/spacing.scss @@ -16,9 +16,10 @@ $spacing-13: 64px; $spacing-14: 72px; $spacing-15: 80px; $spacing-16: 96px; -$all-spacings: spacing-00, spacing-01, spacing-02, spacing-03, spacing-04, - spacing-05, spacing-06, spacing-07, spacing-08, spacing-09, spacing-10, - spacing-11, spacing-12, spacing-13, spacing-14, spacing-15, spacing-16; +$all-spacings: + spacing-00, spacing-01, spacing-02, spacing-03, spacing-04, spacing-05, + spacing-06, spacing-07, spacing-08, spacing-09, spacing-10, spacing-11, + spacing-12, spacing-13, spacing-14, spacing-15, spacing-16; :root { --spacing-00: #{$spacing-00}; diff --git a/services/web/frontend/stylesheets/bootstrap-5/main-style.scss b/services/web/frontend/stylesheets/bootstrap-5/main-style.scss index eb705b461d..73294452ef 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/main-style.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/main-style.scss @@ -7,7 +7,6 @@ @import '../../fonts/noto-serif/noto-serif.css'; @import '../../fonts/open-dyslexic-mono/open-dyslexic-mono.css'; @import '../../fonts/material-symbols/material-symbols.css'; -@import '../../fonts/font-awesome/font-awesome.css'; // Vendor CSS // TODO Bootstrap 5: Check whether this works with Bootstrap 5, and whether we can replace it diff --git a/services/web/frontend/stylesheets/bootstrap-5/modals/contact-us-modal.scss b/services/web/frontend/stylesheets/bootstrap-5/modals/contact-us-modal.scss index 7ac5e62cbc..a0a373c94b 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/modals/contact-us-modal.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/modals/contact-us-modal.scss @@ -60,15 +60,6 @@ span { text-decoration: underline; } - - .fa { - color: inherit; - text-decoration: none; - } - } - - .fa { - color: var(--neutral-30); } } diff --git a/services/web/frontend/stylesheets/bootstrap-5/modules/symbol-palette.scss b/services/web/frontend/stylesheets/bootstrap-5/modules/symbol-palette.scss index dd6d5f64f8..a0a1d4b716 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/modules/symbol-palette.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/modules/symbol-palette.scss @@ -199,9 +199,5 @@ .upgrade-benefits { column-count: 2; - - li { - display: flex; - } } } diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/admin/admin.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/admin/admin.scss index a4bfa532e3..9e5b330d3e 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/admin/admin.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/admin/admin.scss @@ -96,3 +96,7 @@ // firefox does not show markers for block items display: list-item; } + +.expiration-label { + vertical-align: super; +} diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/chat.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/chat.scss index b0e50cd95d..5d39e6b414 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/chat.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/chat.scss @@ -211,7 +211,8 @@ border-radius: 50%; display: inline-block; line-height: 32px; - background-color: var(--bg-light-secondary); + background-color: var(--bg-secondary-themed); + color: var(--content-primary-themed); .material-symbols { font-size: 32px; @@ -219,6 +220,7 @@ } .chat-empty-state-title { + color: var(--content-primary-themed); font-size: var(--font-size-02); line-height: var(--line-height-02); font-weight: bold; @@ -227,7 +229,7 @@ .chat-empty-state-body { font-size: var(--font-size-02); line-height: var(--line-height-02); - color: var(--content-secondary); + color: var(--content-secondary-themed); } } diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/file-tree.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/file-tree.scss index 39eb3f2d34..5caabc5889 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/file-tree.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/file-tree.scss @@ -107,10 +107,6 @@ &:active { color: var(--file-tree-expand-button-color); } - - .material-symbols { - font-size: 16px; - } } .file-tree { diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/ide.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/ide.scss index 8fe5ced008..a60298a53f 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/ide.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/ide.scss @@ -248,6 +248,15 @@ $editor-toggler-bg-dark-color: color.adjust( top: 50%; background-color: var(--editor-toggler-bg-color); + .material-symbols { + font-size: var(--font-size-02); + -moz-osx-font-smoothing: grayscale; + font-weight: bold; + color: var(--white); + user-select: none; + pointer-events: none; + } + &:hover, &:focus { outline: none; @@ -262,37 +271,11 @@ $editor-toggler-bg-dark-color: color.adjust( inset: 0 -3px; } - &::after { - font-family: FontAwesome; /* stylelint-disable-line font-family-no-missing-generic-family-keyword */ - -moz-osx-font-smoothing: grayscale; - font-size: 65%; - font-weight: bold; - color: var(--white); - user-select: none; - pointer-events: none; - } - &:hover { background-color: var(--bg-accent-01); } } -.custom-toggler-east::after { - content: '\f105'; -} - -.custom-toggler-west::after { - content: '\f104'; -} - -.custom-toggler-closed.custom-toggler-east::after { - content: '\f104'; -} - -.custom-toggler-closed.custom-toggler-west::after { - content: '\f105'; -} - .vertical-resize-handle { height: 6px; background-color: var(--editor-resizer-bg-color); diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/left-menu.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/left-menu.scss index e40c6159f6..044a4ecefd 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/left-menu.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/left-menu.scss @@ -129,20 +129,6 @@ } } - .left-menu-setting-position { - position: relative; - - .left-menu-setting { - margin-top: 0 !important; - } - - .left-menu-setting-icon { - position: absolute; - right: 65%; - top: 25%; - } - } - .left-menu-setting { padding: 0 var(--spacing-02); display: flex; diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/logs.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/logs.scss index 912a8b86e5..4f2d4146bf 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/logs.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/logs.scss @@ -78,6 +78,7 @@ display: flex; align-items: center; border: none; + gap: var(--spacing-03); } .log-entry-header-button { @@ -88,6 +89,8 @@ flex: 1; border-radius: var(--border-radius-base); padding: var(--spacing-02) var(--spacing-03) var(--spacing-02) 0; + overflow: hidden; + user-select: text; &:hover { cursor: pointer; diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/online-users.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/online-users.scss index 2a03c22c19..a37152854d 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/online-users.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/online-users.scss @@ -114,8 +114,8 @@ min-width: var(--online-users-circle-size); height: var(--online-users-circle-size); line-height: calc( - var(--online-users-circle-size) - 2 * var(--online-users-border-size) - 2 * - var(--online-users-circle-padding) + var(--online-users-circle-size) - 2 * var(--online-users-border-size) - + 2 * var(--online-users-circle-padding) ); font-size: var(--font-size-01); text-align: center; diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/outline.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/outline.scss index cc815ea058..f915f16994 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/outline.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/outline.scss @@ -111,12 +111,14 @@ } .outline-item-no-children { - padding-left: 0; + padding-left: 26px; } .outline-item-link { flex-grow: 1; padding: var(--spacing-02); + font-size: var(--font-size-02); + line-height: var(--line-height-02); } } diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/pdf.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/pdf.scss index 4c59352df3..57d467eccc 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/pdf.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/pdf.scss @@ -68,6 +68,12 @@ border-radius: var(--border-radius-full); box-shadow: 0 2px 4px 0 #1e253029; } + + .synctex-control { + .synctex-control-icon { + font-weight: normal; + } + } } .pdf .toolbar.toolbar-pdf { diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/rail.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/rail.scss index bc8e42daaa..46f30a3028 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/rail.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/rail.scss @@ -65,6 +65,10 @@ body { position: relative; overflow: visible; + &.disabled { + color: var(--content-disabled-themed); + } + &:visited, &:focus { color: var(--ide-rail-color); diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/review-panel-new.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/review-panel-new.scss index cf90ef5040..1b950400a5 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/review-panel-new.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/review-panel-new.scss @@ -41,7 +41,7 @@ $rp-type-blue: #6b7797; .review-panel-inner { border-left: none; - border-right: 1px solid var(--border-divider); + border-right: 1px solid var(--border-divider-themed); } .review-panel-header { diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/settings.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/settings.scss index 02ce2cca30..e443b6e8ec 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/settings.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/settings.scss @@ -47,6 +47,10 @@ padding: 0; } +.modal-backdrop.show.ide-settings-modal-transparent-backdrop { + opacity: 0; +} + .ide-settings-section { padding-top: var(--spacing-06); padding-bottom: var(--spacing-05); diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/toolbar-redesign.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/toolbar-redesign.scss index a4df2e4e36..10ef617386 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/toolbar-redesign.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/toolbar-redesign.scss @@ -148,6 +148,36 @@ vertical-align: middle; } } + + .ide-redesign-toolbar-logos { + display: flex; + } + + .ide-redesign-toolbar-cobranding-separator { + $separator-height: 16px; + + border-radius: var(--border-radius-base); + width: 1px; + height: $separator-height; + margin-top: math.div($toolbar-height - $separator-height, 2); + + // To account for the 1px border of the rail so that they're aligned. + margin-left: calc(var(--spacing-02) - 1px); + margin-right: var(--spacing-01); + background: var(--redesign-toolbar-border-divider); + } + + .ide-redesign-toolbar-cobranding-link { + display: inline-flex; + flex-direction: column; + justify-content: center; + padding: 0 var(--spacing-02); + } + + .ide-redesign-toolbar-cobranding-logo { + max-height: calc($toolbar-height - var(--spacing-04) * 2); + max-width: 150px; + } } .ide-redesign-toolbar-menu-bar { diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/toolbar.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/toolbar.scss index 7b3decc101..367d1b443c 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/toolbar.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/toolbar.scss @@ -435,9 +435,7 @@ } .toggle-switch-input { - position: absolute; - opacity: 0; - pointer-events: none; + @include visually-hidden; } .toggle-switch-input:disabled + .toggle-switch-label { diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/plans.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/plans.scss index 65b1e8b12c..9894af31f7 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/plans.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/plans.scss @@ -23,6 +23,7 @@ $z-index-group-member-picker-list: 1; --border-radius-large-new: 16px; // TODO: put this variable in a decent location padding-top: $header-height; + overflow: unset; .container { padding: 0 var(--spacing-06); @@ -1081,10 +1082,6 @@ $z-index-group-member-picker-list: 1; .plans-card-price-container-mobile { display: flex; align-items: baseline; - - .light-gray-text:has(.billed-annually-disclaimer) { - align-self: center; - } } .group-plans-card-price-container-mobile { @@ -1479,6 +1476,10 @@ $z-index-group-member-picker-list: 1; .plans-cta { display: block; + + a { + display: block; + } } .plans-faq-tabs { diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/project-list-ds-nav.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/project-list-ds-nav.scss index e87c41f22f..d71de37470 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/project-list-ds-nav.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/project-list-ds-nav.scss @@ -105,9 +105,8 @@ > .dropdown-menu { // navbar + new-project spacing + new-project button height (36px) + extra padding max-height: calc( - 100vh - #{$header-height} - var(--spacing-03) - var(--spacing-08) - 36px - var( - --spacing-05 - ) + 100vh - #{$header-height} - var(--spacing-03) - var(--spacing-08) - + 36px - var(--spacing-05) ); overflow: auto; } diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/register.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/register.scss index 85711e1609..90a90f32ce 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/register.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/register.scss @@ -1,3 +1,8 @@ +.registration-message { + text-align: center; + padding-bottom: var(--spacing-07); +} + .register-container { h1 { @include heading-sm; @@ -33,9 +38,6 @@ } .registration-message { - text-align: center; - padding-bottom: var(--spacing-07); - .registration-message-heading { color: var(--neutral-70); font-size: var(--font-size-05); diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/templates-v2.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/templates-v2.scss index a829bb80d5..15d885400c 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/templates-v2.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/templates-v2.scss @@ -47,11 +47,11 @@ text-decoration: none; &:visited { - color: var(--green-50); + color: var(--link-web-visited); } &:hover { - color: var(--green-60); + color: var(--link-web-hover); } &.active { diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/website-redesign.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/website-redesign.scss index 8164f1eedd..2aecaa8390 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/website-redesign.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/website-redesign.scss @@ -386,7 +386,7 @@ } .inline-green-link { - color: var(--green-50); + color: var(--link-web); padding: 0; text-decoration: underline; @@ -395,7 +395,7 @@ text-decoration-skip-ink: none; &:hover { - color: var(--green-60); + color: var(--link-web-hover); } // TODO: this is copied directly from the `.less` file, migrate this to scss @@ -673,7 +673,7 @@ } // most of these are here to replace rules from core/type.less - blockquote { + blockquote.cms-quote { @include heading-lg; border-left: none; diff --git a/services/web/frontend/stylesheets/components/card.less b/services/web/frontend/stylesheets/components/card.less index 67e7bfc512..78031e2c30 100644 --- a/services/web/frontend/stylesheets/components/card.less +++ b/services/web/frontend/stylesheets/components/card.less @@ -146,7 +146,8 @@ padding: var(--spacing-13); text-align: center; background-size: cover; - background-image: linear-gradient( + background-image: + linear-gradient( to right, rgba(0, 0, 0, 0.4) 0%, var(--dark-jungle-green) 25%, @@ -176,7 +177,8 @@ color: var(--white); padding: var(--spacing-13); background-size: cover; - background-image: linear-gradient( + background-image: + linear-gradient( to right, rgba(0, 0, 0, 0.4) 0%, var(--dark-jungle-green) 20%, diff --git a/services/web/frontend/stylesheets/components/footer.less b/services/web/frontend/stylesheets/components/footer.less index b36f51ab11..e966d3a9dc 100644 --- a/services/web/frontend/stylesheets/components/footer.less +++ b/services/web/frontend/stylesheets/components/footer.less @@ -279,12 +279,12 @@ footer.site-footer { } .fat-footer-sections { - grid-template-columns: repeat(6, 1fr); + grid-template-columns: repeat(7, 1fr); grid-template-rows: auto; } .footer-section:last-of-type { - grid-column: 6; + grid-column: 7; } } } diff --git a/services/web/frontend/stylesheets/components/navbar.less b/services/web/frontend/stylesheets/components/navbar.less index 06c3346a35..1a329e3ce1 100755 --- a/services/web/frontend/stylesheets/components/navbar.less +++ b/services/web/frontend/stylesheets/components/navbar.less @@ -7,6 +7,11 @@ // Provide a static navbar from which we expand to create full-width, fixed, and // other navbar variations. +.no-scroll { + height: 100vh; + overflow: hidden; +} + .navbar { position: relative; min-height: @navbar-height; // Ensure a navbar always shows (e.g., without a .navbar-brand in collapsed mode) diff --git a/services/web/frontend/stylesheets/core/grid.less b/services/web/frontend/stylesheets/core/grid.less index 7f4d3264c9..a45ef73f4f 100755 --- a/services/web/frontend/stylesheets/core/grid.less +++ b/services/web/frontend/stylesheets/core/grid.less @@ -74,45 +74,3 @@ @media (min-width: @screen-lg-min) { .make-grid(lg); } - -.website-redesign { - // whitelist for the pages to use the new grid - // TODO: remove this whitelist once all pages are using the new grid - .plans-new-design, - .plans-page { - // Container widths - // - // Set the container width, and override it for fixed navbars in media queries. - - .container { - .container-fixed(@grid-gutter-width-new); - - @media (min-width: @screen-sm-min) { - width: @container-sm; - } - @media (min-width: @screen-md-min) { - width: @container-md; - } - @media (min-width: @screen-lg-min) { - width: @container-lg; - } - @media (min-width: @screen-xl-min) { - width: @container-xl; - } - } - - // Row - // - // Rows contain and clear the floats of your columns. - - .row { - .make-row(@grid-gutter-width-new); - } - - // Columns - // - // Common styles for small and large grid columns - - .make-grid-columns(@grid-gutter-width-new); - } -} diff --git a/services/web/frontend/stylesheets/core/utilities.less b/services/web/frontend/stylesheets/core/utilities.less index 227ebad4b1..3e9e2dbc33 100755 --- a/services/web/frontend/stylesheets/core/utilities.less +++ b/services/web/frontend/stylesheets/core/utilities.less @@ -11,10 +11,12 @@ .center-block { .center-block(); } -.pull-right { +.pull-right, +.float-end { float: right !important; } -.pull-left { +.pull-left, +.float-start { float: left !important; } diff --git a/services/web/frontend/stylesheets/main-style.less b/services/web/frontend/stylesheets/main-style.less index 040c6ac695..1898c6be66 100644 --- a/services/web/frontend/stylesheets/main-style.less +++ b/services/web/frontend/stylesheets/main-style.less @@ -101,10 +101,8 @@ @import 'app/project-list.less'; @import 'app/project-list-react.less'; @import 'app/editor.less'; -@import 'app/plans.less'; @import 'app/recurly.less'; @import 'app/bonus.less'; -@import 'app/register.less'; @import 'app/blog.less'; @import 'app/features.less'; @import 'app/templates.less'; @@ -118,14 +116,12 @@ @import 'app/error-pages.less'; @import 'app/editor/history-v2.less'; @import 'app/open-in-overleaf.less'; -@import 'app/primary-email-check'; @import 'app/grammarly'; @import 'app/front-chat-widget.less'; @import 'app/ol-chat.less'; @import 'app/templates-v2.less'; @import 'app/login-register.less'; @import 'app/import.less'; -@import 'app/website-redesign.less'; @import 'app/add-secondary-email-prompt.less'; @import 'app/confirm-email.less'; @@ -139,7 +135,6 @@ // module styles // TODO: find a way for modules to add styles dynamically @import 'modules/symbol-palette.less'; -@import 'modules/admin-panel.less'; @import 'modules/git-bridge-modal.less'; @import 'modules/group-settings.less'; @import 'modules/onboarding.less'; diff --git a/services/web/frontend/stylesheets/modules/admin-panel.less b/services/web/frontend/stylesheets/modules/admin-panel.less deleted file mode 100644 index e1f57a4738..0000000000 --- a/services/web/frontend/stylesheets/modules/admin-panel.less +++ /dev/null @@ -1,37 +0,0 @@ -.admin-panel-pagination { - display: flex; - justify-content: center; -} - -.phase-badge { - display: inline-block; - font-size: @font-size-small; - white-space: nowrap; - text-align: center; - padding: 3px 7px; - &:extend(.label); - &:extend(.label-info); -} - -.scrollable { - max-height: calc(100vh - 40vh); - overflow-y: auto; -} - -.hr-sect { - display: flex; - flex-basis: 100%; - align-items: center; - color: rgba(0, 0, 0, 0.35); - margin: 8px 0; -} -.hr-sect:before, -.hr-sect:after { - content: ''; - flex-grow: 1; - background: rgba(0, 0, 0, 0.35); - height: 1px; - font-size: 0; - line-height: 0; - margin: 0 8px; -} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 5c6ab42fee..adfb33d8f8 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -159,7 +159,6 @@ "all_the_pros_of_our_standard_plan_plus_unlimited_collab": "All the pros of our standard plan, plus unlimited collaborators per project.", "all_these_experiments_are_available_exclusively": "All these experiments are available exclusively to members of the Labs program. If you sign up, you can choose which experiments you want to try.", "allows_to_search_by_author_title_etc_possible_to_pull_results_directly_from_your_reference_manager_if_connected": "Allows to search by author, title, etc. Possible to pull results directly from your reference manager (if connected).", - "already_have_a_papers_account": "Managing your citations and bibliographies in Overleaf just got way easier! Already have a Papers account? <0>Link your account here.", "already_have_an_account": "Already have an account?", "already_have_sl_account": "Already have an __appName__ account?", "also": "Also", @@ -226,7 +225,6 @@ "basic": "Basic", "basic_compile_time": "Basic compile time", "basic_compile_timeout_on_fast_servers": "Basic compile timeout on fast servers", - "become_an_advisor": "Become an __appName__ advisor", "before_you_use_error_assistant": "Before you use Error Assist", "beta": "Beta", "beta_feature_badge": "Beta feature badge", @@ -237,7 +235,6 @@ "beta_program_opt_in_action": "Opt-In to Beta Program", "beta_program_opt_out_action": "Opt-Out of Beta Program", "bibliographies": "Bibliographies", - "billed_annually": "billed annually", "billed_annually_at": "Billed annually at <0>__price__ <1>(includes plan and any add-ons)", "billed_monthly_at": "Billed monthly at <0>__price__ <1>(includes plan and any add-ons)", "billed_yearly": "billed yearly", @@ -341,7 +338,6 @@ "clearing": "Clearing", "click_here_to_view_sl_in_lng": "Click here to use __appName__ in <0>__lngName__", "click_link_to_proceed": "Click __clickText__ below to proceed.", - "click_to_give_feedback": "Click to give feedback.", "click_to_unpause": "Click to unpause and reactivate your Overleaf premium features.", "clicking_delete_will_remove_sso_config_and_clear_saml_data": "Clicking <0>Delete 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", @@ -359,6 +355,7 @@ "collaborator_chat": "Collaborator chat", "collabratec_account_not_registered": "IEEE Collabratec™ account not registered. Please connect to Overleaf from IEEE Collabratec™ or log in with a different account.", "collabs_per_proj": "__collabcount__ collaborators per project", + "collabs_per_proj_multiple": "Multiple collaborators per project", "collabs_per_proj_single": "__collabcount__ collaborator per project", "collapse": "Collapse", "column_width": "Column width", @@ -479,6 +476,7 @@ "custom": "Custom", "custom_borders": "Custom borders", "customer_resource_portal": "Customer resource portal", + "customer_stories": "Customer stories", "customize": "Customize", "customize_your_group_subscription": "Customize your group subscription", "customizing_figures": "Customizing figures", @@ -549,6 +547,8 @@ "discount": "Discount", "discount_of": "Discount of __amount__", "discover_latex_templates_and_examples": "Discover LaTeX templates and examples to help with everything from writing a journal article to using a specific LaTeX package.", + "discover_research_writing_toolkit": "NEW: Discover the ultimate research writing toolkit", + "discover_research_writing_toolkit_description": "Add AI Assist to get unlimited access to LaTeX-tailored AI tools from Overleaf and Writefull.", "discover_the_fastest_way_to_search_and_cite": "Discover the fastest way to search and cite", "discover_why_over_people_worldwide_trust_overleaf": "Discover why over __count__ million people worldwide trust Overleaf with their work.", "display": "Display", @@ -567,6 +567,7 @@ "document_updated_externally": "Document Updated Externally", "document_updated_externally_detail": "This document was just updated externally. Any recent changes you have made may have been overwritten. To see previous versions, please look in the history.", "documentation": "Documentation", + "documentation_articles": "Documentation and articles", "does_not_contain_or_significantly_match_your_email": "does not contain or significantly match your email", "doesnt_match": "Doesn’t match", "doing_this_allow_log_in_through_institution": "Doing this will allow you to log in to __appName__ through your institution and will reconfirm your institutional email address.", @@ -653,7 +654,7 @@ "email_address": "Email address", "email_address_is_invalid": "Email address is invalid", "email_already_associated_with": "The __email1__ email is already associated with the __email2__ __appName__ account.", - "email_already_registered": "This email is already registered", + "email_already_registered": "This email address is already associated with a different Overleaf account.", "email_already_registered_secondary": "This email is already registered as a secondary email", "email_already_registered_sso": "This email is already registered. Please log in to your account another way and link your account to the new provider via your account settings.", "email_confirmed_onboarding": "Great! Let’s get you set up", @@ -678,7 +679,6 @@ "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” under the <1>Recompile drop-down menu to help you find and fix errors right away.", - "enable_stop_on_first_error_under_recompile_dropdown_menu_v2": "Enable <0>Stop on first error under the <1>Recompile 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", @@ -689,15 +689,12 @@ "enter_the_code": "Enter the 6-digit code sent to __email__.", "enter_the_confirmation_code": "Enter the 6-digit confirmation code sent to __email__.", "enter_the_number_of_licenses_youd_like_to_add_to_see_the_cost_breakdown": "Enter the number of licenses you’d like to add to see the cost breakdown.", - "enter_your_email_address": "Enter your email address", "enter_your_email_address_below_and_we_will_send_you_a_link_to_reset_your_password": "Enter your email address below, and we will send you a link to reset your password", - "enter_your_new_password": "Enter your new password", "equation_generator": "Equation Generator", "equation_preview": "Equation preview", "error": "Error", "error_assist": "Error Assist", "error_log": "Error log", - "error_logs_have_had_an_update": "Error logs have had an update", "error_opening_document": "Error opening document", "error_opening_document_detail": "Sorry, something went wrong opening this document. Please try again.", "error_performing_request": "An error has occurred while performing your request.", @@ -740,7 +737,6 @@ "featured": "Featured", "featured_latex_templates": "Featured LaTeX Templates", "features": "Features", - "features_and_benefits": "Features & Benefits", "features_like_track_changes": "Features like real-time track changes", "february": "February", "figure": "Figure", @@ -787,18 +783,16 @@ "footer_about_us": "About us", "footer_contact_us": "Contact us", "footer_navigation": "Footer navigation", - "footer_plans_and_pricing": "Plans & pricing", "footnotes": "Footnotes", "for_business": "For business", - "for_enterprise": "For enterprise", "for_government": "For government", - "for_individuals_and_groups": "For individuals & groups", + "for_groups_and_organizations": "For groups and organizations", + "for_individuals": "For individuals", "for_large_institutions_and_organizations_need_sitewide_on_premise": "For large institutions and organizations that need site-wide access or an on-premises solution.", "for_more_information_see_managed_accounts_section": "For more information, see the \"Managed Accounts\" section in <0>our terms of use, which you agree to by clicking Accept invitation.", "for_publishers": "For publishers", "for_small_teams_and_departments_who_want_to_write_collaborate": "For small teams and departments who want to write and collaborate easily in LaTeX.", "for_students": "For students", - "for_teaching": "For teaching", "for_teams_and_organizations_who_want_a_streamlined_sso_and_security": "For teams and organizations who want a streamlined sign-on process and our strongest cloud security.", "for_universities": "For universities", "forever": "forever", @@ -864,6 +858,7 @@ "git_bridge_modal_review_access": "<0>You have review access to this project. This means you can pull from __appName__ but you can’t push any changes you make back to this project.", "git_bridge_modal_see_once": "You’ll only see this token once. To delete it or generate a new one, visit Account settings. For detailed instructions and troubleshooting, read our <0>help page.", "git_bridge_modal_use_previous_token": "If you’re prompted for a password, you can use a previously generated Git authentication token. Or you can generate a new one in Account settings. For more support, read our <0>help page.", + "git_clone_project": "Git clone project", "git_gitHub_dropbox_mendeley_papers_and_zotero_integrations": "Git, GitHub, Dropbox, Papers, Zotero, and Mendeley integrations", "git_integration": "Git integration", "git_integration_info": "With Git integration, you can clone your Overleaf projects with Git. For full instructions on how to do this, <0>read our Git Integration help page.", @@ -947,7 +942,9 @@ "have_more_days_to_try": "Have another __days__ days on your trial!", "headers": "Headers", "help": "Help", + "help_and_resources": "Help & resources", "help_articles_matching": "Help articles matching your subject", + "help_guides": "Help guides", "help_improve_overleaf_fill_out_this_survey": "If you would like to help us improve Overleaf, please take a moment to fill out <0>this survey.", "help_improve_screen_reader_fill_out_this_survey": "Help us improve your experience using a screen reader with __appName__ by filling out this quick survey.", "help_shape_the_future_of_overleaf": "Help shape the future of Overleaf", @@ -1019,6 +1016,7 @@ "if_have_existing_can_link": "If you have an existing __appName__ account on another email, you can link it to your __institutionName__ account by clicking __clickText__.", "if_owner_can_link": "If you own the __appName__ account with __email__, you will be allowed to link it to your __institutionName__ institutional account.", "if_you_need_to_customize_your_table_further_you_can": "If you need to customize your table further, you can. Using LaTeX code, you can change anything from table styles and border styles to colors and column widths. <0>Read our guide to using tables in LaTeX to help you get started.", + "if_you_need_to_delete_your_writefull_account": "If you need to delete your Writefull account, go to your Writefull account settings.", "if_you_want_more_than_x_licenses_on_your_plan_we_need_to_add_them_for_you": "If you want more than __count__ licenses on your plan, we need to add them for you. Just click <0>Send request below and we’ll be happy to help.", "if_you_want_to_reduce_the_number_of_licenses_please_contact_support": "If you want to reduce the number of licenses on your plan, please <0>contact customer support.", "if_your_occupation_not_listed_type_full_name": "If your __occupation__ isn’t listed, you can type the full name.", @@ -1093,7 +1091,7 @@ "integrations": "Integrations", "integrations_like_github": "Integrations like GitHub Sync", "interested_in_cheaper_personal_plan": "Would you be interested in the cheaper <0>__price__ Personal plan?", - "introducing_shorter_compile_timeout": "We’re <0>introducing a shorter compile timeout limit. This project currently exceeds the new limit, so it might not compile from 16th July 2025.", + "introducing_shorter_compile_timeout": "We’re <0>introducing a shorter compile timeout limit. This project currently exceeds the new limit, so it might not compile from 25th August 2025.", "invalid_certificate": "Invalid certificate. Please check the certificate and try again.", "invalid_confirmation_code": "That didn’t work. Please check the code and try again.", "invalid_email": "An email address is invalid", @@ -1132,13 +1130,12 @@ "issued_on": "Issued: __date__", "it": "Italian", "it_looks_like_that_didnt_work_you_can_try_again_or_get_in_touch": "It looks like that didn’t work. You can try again or <0>get in touch with our Support team for more help.", - "it_looks_like_your_account_is_billed_manually": "It looks like your account is being billed manually - adding seats or upgrading your subscription can only be done by the Support team. Please <0>get in touch for help.", - "it_looks_like_your_account_is_billed_manually_upgrading_subscription": "It looks like your account is being billed manually - upgrading your subscription can only be done by the Support team. Please <0>get in touch for help.", + "it_looks_like_your_account_is_billed_manually_purchasing_additional_license_or_upgrading_subscription": "It looks like your account is being billed manually - purchasing additional licenses or upgrading your subscription can only be done by the Support team. Please <0>get in touch for help.", "it_looks_like_your_payment_details_are_missing_please_update_your_billing_information": "It looks like your payment details are missing. Please <0>update your billing information, or <1>get in touch with our Support team for more help.", "italics": "Italics", "ja": "Japanese", "january": "January", - "join_beta_program": "Join beta program", + "join_beta_program": "Join the beta program", "join_labs": "Join Labs", "join_now": "Join now", "join_overleaf_labs": "Join Overleaf Labs", @@ -1167,7 +1164,6 @@ "language": "Language", "language_suggestions": "Language suggestions", "large_or_high-resolution_images_taking_too_long": "Large or high-resolution images taking too long to process. You may be able to <0>optimize them.", - "large_or_high_resolution_images_taking_too_long_to_process": "Large or high-resolution images taking too long to process.", "last_active": "Last Active", "last_active_description": "Last time a project was opened.", "last_edit": "Last edit", @@ -1186,7 +1182,7 @@ "latex_articles_page_title": "Articles - Papers, Presentations, Reports and more", "latex_examples": "LaTeX examples", "latex_examples_page_title": "Examples - Equations, Formatting, TikZ, Packages and More", - "latex_in_thirty_minutes": "LaTeX in 30 minutes", + "latex_in_thirty_minutes": "Learn LaTeX in 30 minutes", "latex_places_figures_according_to_a_special_algorithm": "LaTeX places figures according to a special algorithm. You can use something called ‘placement parameters’ to influence the positioning of the figure. <0>Find out how", "latex_places_tables_according_to_a_special_algorithm": "LaTeX places tables according to a special algorithm. You can use “placement parameters” to influence the position of the table. <0>This article explains how to do this.", "latex_templates": "LaTeX Templates", @@ -1202,7 +1198,6 @@ "learn_more": "Learn more", "learn_more_about": "Learn more about __appName__", "learn_more_about_account": "<0>Learn more about managing your __appName__ account.", - "learn_more_about_compile_timeouts": "<0>Learn more about compile timeouts.", "learn_more_about_emails": "<0>Learn more about managing your __appName__ emails.", "learn_more_about_link_sharing": "Learn more about Link Sharing", "learn_more_about_managed_users": "Learn more about Managed Users.", @@ -1344,6 +1339,7 @@ "may": "May", "maybe_later": "Maybe later", "member_picker": "Select number of users for group plan", + "members_added": "Member(s) added.", "members_management": "Members management", "mendeley": "Mendeley", "mendeley_dynamic_sync_description": "With the Mendeley integration, you can import your references into __appName__. You can either import all your references at once or dynamically search your Mendeley library directly from __appName__.", @@ -1412,7 +1408,6 @@ "new_error_logs_panel": "New error logs panel. Added ‘Go to location in code’ and ‘Go to location in PDF’ buttons (17 June 2025)", "new_file": "New file", "new_folder": "New folder", - "new_font_open_dyslexic": "New font: OpenDyslexic Mono is designed to improve readability for those with dyslexia.", "new_look_and_feel": "New look and feel", "new_look_and_placement_of_the_settings": "New look and placement of the settings", "new_name": "New Name", @@ -1481,7 +1476,6 @@ "notification_features_upgraded_by_affiliation": "Good news! Your affiliated organization __institutionName__ has an Overleaf subscription, and you now have access to all of Overleaf’s Professional features.", "notification_personal_and_group_subscriptions": "We’ve spotted that you’ve got <0>more than one active __appName__ subscription. To avoid paying more than you need to, <1>review your subscriptions.", "notification_personal_subscription_not_required_due_to_affiliation": " Good news! Your affiliated organization __institutionName__ has an Overleaf subscription, and you now have access to Overleaf’s Professional features through your affiliation. You can cancel your individual subscription without losing access to any features.", - "notification_project_invite": "__userName__ would like you to join __projectName__ Join Project", "notification_project_invite_accepted_message": "You’ve joined __projectName__", "notification_project_invite_message": "__userName__ would like you to join __projectName__", "november": "November", @@ -1507,10 +1501,10 @@ "ongoing_experiments": "Ongoing experiments", "online_latex_editor": "Online LaTeX Editor", "only_group_admin_or_managers_can_delete_your_account_1": "By becoming a managed user, your organization will have admin rights over your account and control over your stuff, including the right to close your account and access, delete and share your stuff. As a result:", + "only_group_admin_or_managers_can_delete_your_account_10": "If you have an individual subscription, we’ll automatically terminate it and cancel its renewal when your account becomes managed. To request a pro-rata refund for the remainder, please contact Support.", "only_group_admin_or_managers_can_delete_your_account_3": "Your group admin and group managers will be able to reassign ownership of your projects to another group member.", "only_group_admin_or_managers_can_delete_your_account_6": "Only your group admin or group managers will be able to delete your account or change your account back into an unmanaged account.", "only_group_admin_or_managers_can_delete_your_account_7": "Only your group admin or group managers will be able to delete your account or change your account into an unmanaged account.", - "only_group_admin_or_managers_can_delete_your_account_8": "We’ll cancel the renewal of your subscription, reach out to Support to request a pro-rata refund. Your individual subscription will be terminated when your account becomes managed.", "only_group_admin_or_managers_can_delete_your_account_9": "Once you have become a managed user, <0>you yourself cannot change it back to an unmanaged account. <1>Learn more about managed Overleaf accounts.", "only_importer_can_refresh": "Only the person who originally imported this __provider__ file can refresh it.", "open_action_menu": "Open __name__ action menu", @@ -1539,7 +1533,6 @@ "other_sessions": "Other Sessions", "other_ways_to_log_in": "Other ways to log in", "our_team_will_get_back_to_you_shortly": "Our team will get back to you shortly.", - "our_values": "Our values", "out_of_sync": "Out of sync", "out_of_sync_detail": "Sorry, this file has gone out of sync and we need to do a full refresh.<0 /><1>Please see this help guide for more information", "output_file": "Output file", @@ -1587,9 +1580,8 @@ "password_change_successful": "Password changed", "password_compromised_try_again_or_use_known_device_or_reset": "The password you’ve entered is on a <0>public list of compromised passwords. Please try logging in from a device you’ve previously used or <1>reset your password", "password_managed_externally": "Password settings are managed externally", - "password_reset": "Password Reset", + "password_reset": "Password reset", "password_reset_email_sent": "You have been sent an email to complete your password reset.", - "password_reset_sentence_case": "Password reset", "password_reset_token_expired": "Your password reset token has expired. Please request a new password reset email and follow the link there.", "password_too_long_please_reset": "Maximum password length exceeded. Please reset your password.", "password_updated": "Password updated", @@ -1600,6 +1592,10 @@ "pause_subscription": "Pause subscription", "pause_subscription_for": "Pause subscription for", "pay_now": "Pay now", + "payment_error_3ds_failed": "We couldn’t complete your payment because authentication wasn’t successful. Please try again or choose a different payment method. If the problem continues please <0>contact us.", + "payment_error_generic": "Sorry, something went wrong. Please try again. If the problem continues please <0>contact us.", + "payment_error_intermittent_error": "We were unable to process your payment. Please try again later or <0>contact us for assistance.", + "payment_error_update_payment_method": "Your payment was declined. Please <0>update your billing information and try again.", "payment_method_accepted": "__paymentMethod__ accepted", "payment_provider_unreachable_error": "Sorry, there was an error talking to our payment provider. Please try again in a few moments.\nIf you are using any ad or script blocking extensions in your browser, you may need to temporarily disable them.", "payment_summary": "Payment summary", @@ -1625,7 +1621,6 @@ "per_month": "per month", "per_month_billed_annually": "per month, billed annually", "per_month_x_annually": "per month, __price__ annually", - "per_user_month": "per user / month", "per_user_year": "per user / year", "per_year": "per year", "percent_is_the_percentage_of_the_line_width": "% is the percentage of the line width", @@ -1638,7 +1633,6 @@ "plan": "Plan", "plan_tooltip": "You’re on the __plan__ plan. Click to find out how to make the most of your Overleaf premium features.", "planned_maintenance": "Planned Maintenance", - "plans_amper_pricing": "Plans & Pricing", "plans_and_pricing": "Plans and Pricing", "plans_and_pricing_lowercase": "plans and pricing", "please_ask_the_project_owner_to_upgrade_more_collaborators": "Please ask the project owner to upgrade their plan to allow more collaborators.", @@ -1683,13 +1677,12 @@ "postal_code": "Postal code", "premium": "Premium", "premium_feature": "Premium feature", - "premium_features": "Premium features", "premium_plan_label": "You’re using Overleaf Premium", "presentation": "Presentation", "presentation_mode": "Presentation mode", - "press_and_awards": "Press & awards", "previous_page": "Previous page", "price": "Price", + "pricing": "Pricing", "primarily_work_study_question": "Where do you primarily work or study?", "primarily_work_study_question_company": "Company", "primarily_work_study_question_government": "Government", @@ -1711,15 +1704,16 @@ "processing": "processing", "processing_uppercase": "Processing", "processing_your_request": "Please wait while we process your request.", + "product": "Product", "professional": "Professional", "progress_bar_percentage": "Progress bar from 0 to 100%", "project": "project", "project_approaching_file_limit": "This project is approaching the file limit", - "project_failed_to_compile": "Your project failed to compile", "project_figure_modal": "Project", "project_files": "Project files", "project_flagged_too_many_compiles": "This project has been flagged for compiling too often. The limit will be lifted shortly.", "project_has_too_many_files": "This project has reached the 2000 file limit", + "project_history_list": "Project history list", "project_last_published_at": "Your project was last published at", "project_layout_sharing_submission": "Project Layout, Sharing, and Submission", "project_linked_to": "This project is linked to", @@ -1801,6 +1795,7 @@ "redo": "Redo", "reduce_costs_group_licenses": "You can cut down on paperwork and reduce costs with our discounted group licenses.", "reference_error_relink_hint": "If this error persists, try re-linking your account here:", + "reference_manager_lowercase": "reference manager", "reference_manager_searched_groups": "__provider__ search groups", "reference_managers": "Reference managers", "reference_search": "Advanced reference search", @@ -1863,7 +1858,6 @@ "republish": "Republish", "request_new_password_reset_email": "Request a new password reset email", "request_overleaf_common": "Request Overleaf Commons", - "request_password_reset": "Request password reset", "request_password_reset_to_reconfirm": "Request password reset email to reconfirm", "request_reconfirmation_email": "Request reconfirmation email", "request_sent_thank_you": "Message sent! Our team will review it and reply by email.", @@ -2072,6 +2066,7 @@ "six_per_project": "6 per project", "skip": "Skip", "skip_to_content": "Skip to content", + "solutions": "Solutions", "something_not_right": "Something’s not right", "something_went_wrong": "Something went wrong", "something_went_wrong_canceling_your_subscription": "Something went wrong canceling your subscription. Please contact Support.", @@ -2172,6 +2167,8 @@ "stop_on_validation_error": "Check syntax before compile", "store_your_work": "Store your work on your own infrastructure", "stretch_width_to_text": "Stretch width to text", + "stripe_error_please_wait_and_try_again": "There is an error with our payment processor, please wait a few minutes and try again.", + "stripe_key_error_please_contact_support": "There is an error with our payment processor, please <0>contact support.", "strongly_agree": "Strongly agree", "strongly_disagree": "Strongly disagree", "student": "Student", @@ -2332,7 +2329,6 @@ "this_project_has_more_than_max_collabs": "This project has more than the maximum number of collaborators allowed on the project owner’s Overleaf plan. This means you could lose edit access from __linkSharingDate__.", "this_project_is_public": "This project is public and can be edited by anyone with the URL.", "this_project_is_public_read_only": "This project is public and can be viewed but not edited by anyone with the URL", - "this_project_need_more_time_to_compile": "It looks like this project may need more time to compile than our free plan allows.", "this_project_will_appear_in_your_dropbox_folder_at": "This project will appear in your Dropbox folder at ", "this_tool_helps_you_insert_figures": "This tool helps you insert figures into your project without needing to write the LaTeX code. The following information explains more about the options in the tool and how to further customize your figures.", "this_tool_helps_you_insert_simple_tables_into_your_project_without_writing_latex_code_give_feedback": "This tool helps you insert simple tables into your project without writing LaTeX code. This tool is new, so please <0>give us feedback and look out for additional functionality coming soon.", @@ -2351,7 +2347,6 @@ "to_confirm_transfer_enter_email_address": "To accept the invitation, enter the email address linked to your account.", "to_confirm_unlink_all_users_enter_email": "To confirm you want to unlink all users, enter your email address:", "to_continue_using_upgrade_or_change_your_browser": "To continue using __appName__ without problems you need to upgrade or change to a <0>supported browser.", - "to_delete_your_writefull_account": "To delete your Writefull account, or to check if you have one, please contact __email__.", "to_fix_this_you_can": "To fix this, you can:", "to_fix_this_you_can_ask_the_github_repository_owner": "To fix this, you can ask the GitHub repository owner (<0>__repoOwnerEmail__) to renew their __appName__ subscription and reconnect the project.", "to_insert_or_move_a_caption_make_sure_tabular_is_directly_within_table": "To insert or move a caption, make sure \\begin{tabular} is directly within a table environment", @@ -2453,7 +2448,6 @@ "try_for_free": "Try for free", "try_it_for_free": "Try it for free", "try_now": "Try Now", - "try_papers_for_free": "Try Papers for free", "try_premium_for_free": "Try Premium for free", "try_recompile_project_or_troubleshoot": "Please try recompiling the project from scratch, and if that doesn’t help, follow our <0>troubleshooting guide.", "try_relinking_provider": "It looks like you need to re-link your __provider__ account.", @@ -2529,7 +2523,6 @@ "upgrade_to_add_more_collaborators_and_access_collaboration_features": "Upgrade to add more collaborators and access collaboration features like track changes and full project history.", "upgrade_to_get_feature": "Upgrade to get __feature__, plus:", "upgrade_to_review": "Upgrade to Review", - "upgrade_to_unlock_more_time": "Upgrade now to unlock 24x more compile time on our fastest servers.", "upgrade_your_subscription": "Upgrade your subscription", "upload": "Upload", "upload_failed": "Upload failed", @@ -2609,7 +2602,7 @@ "visual_editor_is_only_available_for_tex_files": "Visual Editor is only available for TeX files", "want_access_to_overleaf_premium_features_through_your_university": "Want access to __appName__ premium features through your university?", "want_change_to_apply_before_plan_end": "If you wish this change to apply before the end of your current billing period, please contact us.", - "we_are_unable_to_generate_the_pdf_at_this_time": "We are unable to generate the pdf at this time.", + "we_are_unable_to_generate_the_pdf_at_this_time": "We are unable to generate the PDF at this time.", "we_are_unable_to_opt_you_into_this_experiment": "We are unable to opt you into this experiment at this time, please ensure your organization has allowed this feature, or try again later.", "we_cant_confirm_this_email": "We can’t confirm this email", "we_cant_find_any_sections_or_subsections_in_this_file": "We can’t find any sections or subsections in this file", @@ -2655,9 +2648,13 @@ "work_offline": "Work offline", "work_offline_pull_to_overleaf": "Work offline, then pull to __appName__", "work_or_university_sso": "Work/university single sign-on", + "work_smarter_with_ai_assist": "Work smarter with AI Assist: AI tools built by TeXperts, for LaTeX", + "work_smarter_with_ai_assist_description": "Get help with everything from language suggestions and writing support to generating LaTeX tables and fixing errors.", "work_with_non_overleaf_users": "Work with non Overleaf users", "work_with_other_github_users": "Work with other GitHub users", "write_faster_smarter_with_overleaf_and_writefull_ai_tools": "Write faster, smarter, and with confidence with Overleaf and Writefull AI tools", + "write_smarter_right_now": "Write smarter, right now", + "write_smarter_right_now_description": "Write faster and with confidence with AI Assist—LaTeX-tailored AI tools from Overleaf and Writefull.", "writefull": "Writefull", "writefull_loading_error_body": "Try refreshing the page. If this doesn’t work, try disabling any active browser extensions to check they aren’t blocking Writefull from loading.", "writefull_loading_error_title": "Writefull didn’t load correctly", @@ -2692,7 +2689,7 @@ "you_can_manage_your_reference_manager_integrations_from_your_account_settings_page": "You can manage your reference manager integrations from your <0>account settings page.", "you_can_now_enable_sso": "You can now enable SSO on your group settings page.", "you_can_now_log_in_sso": "You can now log in through your institution and if eligible you will receive <0>__appName__ Professional features.", - "you_can_now_sync_your_papers_library_directly_with_your_overleaf_projects": "You can now sync your Papers library directly with your Overleaf projects", + "you_can_now_search_and_add_references_from_your_rm_library_without_needing_to_import_files": "You can now search and add references from your __referenceManager__ library without needing to import files—just type \\cite{} in your .tex file. Learn more", "you_can_opt_in_and_out_of_the_program_at_any_time_on_this_page": "You can <0>opt in and out of the program at any time on this page", "you_can_request_a_maximum_of_limit_fixes_per_day": "You can request a maximum of __limit__ fixes per day. Please try again tomorrow.", "you_can_select_or_invite_collaborator": "You can select or invite __count__ collaborator on your current plan. Upgrade to add more editors or reviewers.", @@ -2754,7 +2751,6 @@ "your_project_exceeded_collaborator_limit": "Your project exceeded the collaborator limit and access levels were changed. Select a new access level for your collaborators, or upgrade to add more editors or reviewers.", "your_project_exceeded_compile_timeout_limit_on_free_plan": "Your project exceeded the compile timeout limit on our free plan.", "your_project_near_compile_timeout_limit": "Your project is near the compile timeout limit for our free plan.", - "your_project_need_more_time_to_compile": "It looks like your project may need more time to compile than our free plan allows.", "your_projects": "Your projects", "your_questions_answered": "Your questions answered", "your_role": "Your role", diff --git a/services/web/migrations/20210726083523_convert_confirmedAt_strings_to_dates.mjs b/services/web/migrations/20210726083523_convert_confirmedAt_strings_to_dates.mjs index 8dbac841c6..064e365a86 100644 --- a/services/web/migrations/20210726083523_convert_confirmedAt_strings_to_dates.mjs +++ b/services/web/migrations/20210726083523_convert_confirmedAt_strings_to_dates.mjs @@ -1,12 +1,35 @@ -import updateStringDates from '../scripts/confirmed_at_to_dates.mjs' +import { db } from '../app/src/infrastructure/mongodb.js' +import { batchedUpdate } from '@overleaf/mongo-utils/batchedUpdate.js' const tags = ['saas'] -const migrate = async client => { - await updateStringDates() +const migrate = async () => { + await batchedUpdate( + db.users, + { 'emails.confirmedAt': { $type: 'string' } }, + async function (batch) { + for (const user of batch) { + for (const email of user.emails) { + if (typeof email.confirmedAt === 'string') { + await db.users.updateOne( + { _id: user._id, 'emails.email': email.email }, + { + $set: { + 'emails.$.confirmedAt': new Date( + email.confirmedAt.replace(/ UTC$/, '') + ), + }, + } + ) + } + } + } + }, + { emails: 1 } + ) } -const rollback = async client => { +const rollback = async () => { /* nothing to do */ } diff --git a/services/web/migrations/20210726083523_convert_split_tests_assigned_at_strings_to_dates.mjs b/services/web/migrations/20210726083523_convert_split_tests_assigned_at_strings_to_dates.mjs index 094f3501c3..b13d636d0f 100644 --- a/services/web/migrations/20210726083523_convert_split_tests_assigned_at_strings_to_dates.mjs +++ b/services/web/migrations/20210726083523_convert_split_tests_assigned_at_strings_to_dates.mjs @@ -1,12 +1,29 @@ -import updateStringDates from '../scripts/split_tests_assigned_at_to_dates.mjs' +import { db } from '../app/src/infrastructure/mongodb.js' +import { batchedUpdate } from '@overleaf/mongo-utils/batchedUpdate.js' const tags = ['saas'] -const migrate = async client => { - await updateStringDates() +const migrate = async () => { + await batchedUpdate( + db.users, + { splitTests: { $exists: true } }, + async function (batch) { + for (const user of batch) { + const splitTests = user.splitTests + for (const splitTest of Object.values(user.splitTests)) { + for (const variant of splitTest) { + variant.assignedAt = new Date(variant.assignedAt) + } + } + + await db.users.updateOne({ _id: user._id }, { $set: { splitTests } }) + } + }, + { splitTests: 1 } + ) } -const rollback = async client => { +const rollback = async () => { /* nothing to do */ } diff --git a/services/web/migrations/20221111111111_ce_sp_convert_archived_state.mjs b/services/web/migrations/20221111111111_ce_sp_convert_archived_state.mjs index 6adc8adb6e..284429cd2c 100644 --- a/services/web/migrations/20221111111111_ce_sp_convert_archived_state.mjs +++ b/services/web/migrations/20221111111111_ce_sp_convert_archived_state.mjs @@ -1,9 +1,56 @@ -import runScript from '../scripts/convert_archived_state.mjs' +import { batchedUpdate } from '@overleaf/mongo-utils/batchedUpdate.js' +import { promiseMapWithLimit } from '@overleaf/promise-utils' +import { db } from '../app/src/infrastructure/mongodb.js' +import _ from 'lodash' const tags = ['server-ce', 'server-pro'] +const WRITE_CONCURRENCY = parseInt(process.env.WRITE_CONCURRENCY, 10) || 10 + +function getAllUserIds(project) { + return _.unionWith( + [project.owner_ref], + project.collaberator_refs, + project.readOnly_refs, + project.tokenAccessReadAndWrite_refs, + project.tokenAccessReadOnly_refs, + (a, b) => a.toString() === b.toString() + ) +} + +async function migrateField(field) { + await batchedUpdate( + db.projects, + { [field]: false }, + { $set: { [field]: [] } } + ) + + await batchedUpdate( + db.projects, + { [field]: true }, + async nextBatch => { + await promiseMapWithLimit(WRITE_CONCURRENCY, nextBatch, async project => { + await db.projects.updateOne( + { _id: project._id }, + { $set: { [field]: getAllUserIds(project) } } + ) + }) + }, + { + _id: 1, + owner_ref: 1, + collaberator_refs: 1, + readOnly_refs: 1, + tokenAccessReadAndWrite_refs: 1, + tokenAccessReadOnly_refs: 1, + } + ) +} + const migrate = async () => { - await runScript('FIRST,SECOND') + for (const field of ['archived', 'trashed']) { + await migrateField(field) + } } const rollback = async () => {} diff --git a/services/web/migrations/20250620152657_ensure_collaborator_arrays.mjs b/services/web/migrations/20250620152657_ensure_collaborator_arrays.mjs new file mode 100644 index 0000000000..3caa210d41 --- /dev/null +++ b/services/web/migrations/20250620152657_ensure_collaborator_arrays.mjs @@ -0,0 +1,29 @@ +import { batchedUpdate } from '@overleaf/mongo-utils/batchedUpdate.js' +import { db } from '../app/src/infrastructure/mongodb.js' + +const tags = ['server-ce', 'server-pro', 'saas'] + +const migrate = async () => { + const fields = [ + 'collaberator_refs', + 'pendingEditor_refs', + 'pendingReviewer_refs', + 'readOnly_refs', + 'reviewer_refs', + ] + for (const field of fields) { + await batchedUpdate( + db.projects, + { [field]: { $type: 'null' } }, + { $set: { [field]: [] } } + ) + } +} + +const rollback = async () => {} + +export default { + tags, + migrate, + rollback, +} diff --git a/services/web/migrations/20250702203054_recurly_subscription_id_index.mjs b/services/web/migrations/20250702203054_recurly_subscription_id_index.mjs new file mode 100644 index 0000000000..49d7413e11 --- /dev/null +++ b/services/web/migrations/20250702203054_recurly_subscription_id_index.mjs @@ -0,0 +1,23 @@ +import Helpers from './lib/helpers.mjs' + +const tags = ['saas'] + +const indexes = [ + { key: { recurlySubscription_id: 1 }, name: 'recurlySubscription_id_1' }, +] + +const migrate = async client => { + const { db } = client + await Helpers.addIndexesToCollection(db.subscriptions, indexes) +} + +const rollback = async client => { + const { db } = client + await Helpers.dropIndexesFromCollection(db.subscriptions, indexes) +} + +export default { + tags, + migrate, + rollback, +} diff --git a/services/web/migrations/lib/adapter.mjs b/services/web/migrations/lib/adapter.mjs index e1d4c072d1..0f4cecdd05 100644 --- a/services/web/migrations/lib/adapter.mjs +++ b/services/web/migrations/lib/adapter.mjs @@ -10,9 +10,9 @@ class Adapter { if ( !process.env.SKIP_TAG_CHECK && !process.argv.includes('create') && - !(process.argv.includes('-t') || process.argv.includes('--tags')) + !(process.argv.includes('-t') || process.argv.includes('--tag')) ) { - console.error("ERROR: must pass tags using '-t' or '--tags', exiting") + console.error("ERROR: must pass tags using '-t' or '--tag', exiting") process.exit(1) } this.params = params || {} diff --git a/services/web/modules/full-project-search/frontend/js/components/full-project-match-counts.tsx b/services/web/modules/full-project-search/frontend/js/components/full-project-match-counts.tsx new file mode 100644 index 0000000000..c20187ae7e --- /dev/null +++ b/services/web/modules/full-project-search/frontend/js/components/full-project-match-counts.tsx @@ -0,0 +1,32 @@ +import React, { FC } from 'react' +import LoadingSpinner from '@/shared/components/loading-spinner' +import { MatchedFile } from '../util/search-snapshot' +import { useTranslation } from 'react-i18next' + +export const FullProjectMatchCounts: FC<{ + loading: boolean + matchedFiles?: MatchedFile[] +}> = ({ loading, matchedFiles }) => { + const { t } = useTranslation() + + if (loading) { + return + } + + if (matchedFiles === undefined) { + return null + } + + const totalResults = matchedFiles.flatMap(file => file.hits).length + + if (totalResults === 0) { + return <>{t('project_search_result_count', { count: totalResults })} + } + + return ( + <> + {t('project_search_result_count', { count: totalResults })}{' '} + {t('project_search_file_count', { count: matchedFiles.length })} + + ) +} diff --git a/services/web/modules/full-project-search/frontend/js/components/full-project-search-button.tsx b/services/web/modules/full-project-search/frontend/js/components/full-project-search-button.tsx new file mode 100644 index 0000000000..83031ba595 --- /dev/null +++ b/services/web/modules/full-project-search/frontend/js/components/full-project-search-button.tsx @@ -0,0 +1,57 @@ +import React, { FC, useMemo } from 'react' +import OLTooltip from '@/features/ui/components/ol/ol-tooltip' +import classnames from 'classnames' +import MaterialIcon from '@/shared/components/material-icon' +import { useLayoutContext } from '@/shared/context/layout-context' +import { useTranslation } from 'react-i18next' +import { TooltipProps } from '@/features/ui/components/bootstrap-5/tooltip' +import { isMac } from '@/shared/utils/os' +import { sendSearchEvent } from '@/features/event-tracking/search-events' + +const FullProjectSearchButton: FC = () => { + const { projectSearchIsOpen, setProjectSearchIsOpen } = useLayoutContext() + const { t } = useTranslation() + + const tooltipProps: Pick< + TooltipProps, + 'id' | 'description' | 'overlayProps' + > = useMemo( + () => ({ + id: 'search', + description: ( + <> +
      {t('search_project')}
      +
      {isMac ? '⇧⌘F' : 'Ctrl+Shift+F'}
      + + ), + overlayProps: { placement: 'bottom' }, + }), + [t] + ) + + return ( + + + + ) +} + +export default FullProjectSearchButton diff --git a/services/web/modules/full-project-search/frontend/js/components/full-project-search-modifiers.tsx b/services/web/modules/full-project-search/frontend/js/components/full-project-search-modifiers.tsx new file mode 100644 index 0000000000..465bf1363a --- /dev/null +++ b/services/web/modules/full-project-search/frontend/js/components/full-project-search-modifiers.tsx @@ -0,0 +1,78 @@ +import React, { useRef, forwardRef, useImperativeHandle } from 'react' +import { SearchQuery } from '@codemirror/search' +import OLTooltip from '@/features/ui/components/ol/ol-tooltip' +import { Form } from 'react-bootstrap' +import { useTranslation } from 'react-i18next' + +export const FullProjectSearchModifiers = forwardRef( + function FullProjectSearchModifiers(props, ref) { + const { t } = useTranslation() + + const caseSensitiveRef = useRef(null) + const regexpRef = useRef(null) + const wholeWordRef = useRef(null) + + useImperativeHandle(ref, () => { + return { + setQuery(query: SearchQuery) { + caseSensitiveRef.current!.checked = query.caseSensitive + regexpRef.current!.checked = query.regexp + wholeWordRef.current!.checked = query.wholeWord + }, + } + }, []) + + return ( +
      + + + + + + + + + + + + + + + +
      + ) + } +) diff --git a/services/web/modules/full-project-search/frontend/js/components/full-project-search-results.tsx b/services/web/modules/full-project-search/frontend/js/components/full-project-search-results.tsx new file mode 100644 index 0000000000..33ae590254 --- /dev/null +++ b/services/web/modules/full-project-search/frontend/js/components/full-project-search-results.tsx @@ -0,0 +1,174 @@ +import React, { FC, useCallback, useEffect, useRef, useState } from 'react' +import { Hit, MatchedFile } from '../util/search-snapshot' +import classnames from 'classnames' +import { CollapsibleFileHeader } from '@/shared/components/collapsible-file-header' +import { MatchedHit } from './matched-hit' +import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path' +import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context' +import { sendSearchEvent } from '@/features/event-tracking/search-events' + +export const FullProjectSearchResults: FC<{ + matchedFiles: MatchedFile[] + currentDocPath: string | null +}> = ({ matchedFiles, currentDocPath }) => { + const [collapsedFiles, setCollapsedFiles] = useState>(new Set()) + const [selectedHit, setSelectedHit] = useState() + + const toggleCollapse = useCallback((path: string) => { + setCollapsedFiles(value => { + const newValue = new Set(value) + if (newValue.has(path)) { + newValue.delete(path) + } else { + newValue.add(path) + } + return newValue + }) + }, []) + + const resultsContainerRef = useRef(null) + + useEffect(() => { + const container = resultsContainerRef.current + if (container) { + const hits = matchedFiles.flatMap(file => file.hits) + + const findSelectedHitIndex = () => + hits.findIndex(hit => hit === selectedHit) + + const listener = (event: KeyboardEvent) => { + if (event.ctrlKey || event.shiftKey || event.altKey || event.metaKey) { + return + } + + if (!matchedFiles) { + return + } + + switch (event.key) { + case 'Enter': + case ' ': // Space + window.setTimeout(() => { + window.dispatchEvent(new Event('editor:focus')) + }) + break + + case 'ArrowUp': + { + event.preventDefault() + let index = findSelectedHitIndex() + if (index === 0) { + index = hits.length + } + index-- + if (index < 0) { + index = 0 + } + setSelectedHit(hits[index]) + } + break + + case 'ArrowDown': + { + event.preventDefault() + let index = findSelectedHitIndex() + index++ + if (index >= hits.length) { + index = 0 + } + setSelectedHit(hits[index]) + } + break + } + } + + container.addEventListener('keydown', listener) + + return () => { + container.removeEventListener('keydown', listener) + } + } + }, [matchedFiles, selectedHit, setSelectedHit]) + + const { findEntityByPath } = useFileTreePathContext() + const { openDocWithId, openFileWithId } = useEditorManagerContext() + + const selectedHitRef = useRef() + + useEffect(() => { + // only open the doc if selectedHit has actually changed + if (selectedHit && selectedHit !== selectedHitRef.current) { + selectedHitRef.current = selectedHit + const selectedFile = matchedFiles.find(file => + file.hits.includes(selectedHit) + ) + if (selectedFile) { + const result = findEntityByPath(selectedFile.path) + if (result) { + sendSearchEvent('search-result-click', { + searchType: 'full-project', + }) + const line = selectedHit.lineIndex + const column = selectedHit.matchIndex + const text = selectedFile.lines[line].substring( + column, + column + selectedHit.length + ) + if (result.type === 'doc') { + openDocWithId(result.entity._id, { + gotoLine: line + 1, + gotoColumn: column, + selectText: text, + }) + } else if (result.type === 'fileRef') { + openFileWithId(result.entity._id) + } + } + } + } + }, [ + findEntityByPath, + matchedFiles, + openDocWithId, + openFileWithId, + selectedHit, + ]) + + const tabbableHit = selectedHit ?? matchedFiles?.[0]?.hits[0] + + return ( +
      + {matchedFiles.map(matchedFile => ( +
      + toggleCollapse(matchedFile.path)} + /> + {!collapsedFiles.has(matchedFile.path) && ( +
      + {matchedFile.hits.map(hit => { + return ( + + ) + })} +
      + )} +
      + ))} +
      + ) +} diff --git a/services/web/modules/full-project-search/frontend/js/components/full-project-search-ui.tsx b/services/web/modules/full-project-search/frontend/js/components/full-project-search-ui.tsx new file mode 100644 index 0000000000..69acc9e38c --- /dev/null +++ b/services/web/modules/full-project-search/frontend/js/components/full-project-search-ui.tsx @@ -0,0 +1,252 @@ +import React, { + FC, + FormEventHandler, + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { useTranslation } from 'react-i18next' +import { useLayoutContext } from '@/shared/context/layout-context' +import { useProjectContext } from '@/shared/context/project-context' +import { + MatchedFile as MatchedFileType, + searchSnapshot, +} from '../util/search-snapshot' +import { SearchQuery } from '@codemirror/search' +import { debugConsole } from '@/utils/debugging' +import useEventListener from '@/shared/hooks/use-event-listener' +import { Col, Form, Row } from 'react-bootstrap' +import OLFormControl from '@/features/ui/components/ol/ol-form-control' +import Button from '@/features/ui/components/bootstrap-5/button' +import Notification from '@/shared/components/notification' +import '../../stylesheets/full-project-search.scss' +import { userStyles } from '@/shared/utils/styles' +import { useUserSettingsContext } from '@/shared/context/user-settings-context' +import { FullProjectMatchCounts } from './full-project-match-counts' +import { FullProjectSearchModifiers } from './full-project-search-modifiers' +import { isMac } from '@/shared/utils/os' +import { PanelHeading } from '@/shared/components/panel-heading' +import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context' +import { createRegExp } from '../util/regexp' +import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context' +import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path' +import { FullProjectSearchResults } from './full-project-search-results' +import { signalWithTimeout } from '@/utils/abort-signal' +import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils' +import RailPanelHeader from '@/features/ide-redesign/components/rail-panel-header' +import { useActiveOverallTheme } from '@/shared/hooks/use-active-overall-theme' + +const FullProjectSearchUI: FC = () => { + const { t } = useTranslation() + const { setProjectSearchIsOpen } = useLayoutContext() + const { projectSnapshot } = useProjectContext() + const { openDocs } = useEditorManagerContext() + const { pathInFolder } = useFileTreePathContext() + const newEditor = useIsNewEditorEnabled() + + const { currentDocument: currentDoc } = useEditorOpenDocContext() + + const [loading, setLoading] = useState(false) + const [error, setError] = useState() + const [matchedFiles, setMatchedFiles] = useState() + + const { userSettings } = useUserSettingsContext() + const { fontFamily, fontSize } = useMemo( + () => userStyles(userSettings), + [userSettings] + ) + const activeOverallTheme = useActiveOverallTheme() + + const abortControllerRef = useRef(null) + + // start fetching the snapshot when the project search UI opens + useEffect(() => { + projectSnapshot.refresh().catch(error => { + debugConsole.error(error) + }) + }, [projectSnapshot]) + + const currentDocPath = useMemo(() => { + return currentDoc && pathInFolder(currentDoc.doc_id) + }, [currentDoc, pathInFolder]) + + const handleSubmit: React.FormEventHandler = useCallback( + async event => { + event.preventDefault() + + setMatchedFiles(undefined) + + abortControllerRef.current?.abort() + abortControllerRef.current = new AbortController() + + const data = new FormData(event.target as HTMLFormElement) + + const searchQuery = new SearchQuery({ + search: data.get('search') as string, + // replace: data.get('replace') as string, + caseSensitive: data.get('caseSensitive') === 'on', + regexp: data.get('regexp') === 'on', + wholeWord: data.get('wholeWord') === 'on', + literal: data.get('regexp') !== 'on', + }) + + if (searchQuery.regexp) { + try { + createRegExp(searchQuery) + } catch (error) { + setError(t('invalid_regular_expression')) + return + } + } + + setLoading(true) + setError(undefined) + try { + await openDocs.awaitBufferedOps( + signalWithTimeout(abortControllerRef.current.signal, 5000) + ) + + await projectSnapshot.refresh() + if (!abortControllerRef.current.signal.aborted) { + const results = await searchSnapshot(projectSnapshot, searchQuery) + setMatchedFiles(results) + } + } catch (error) { + debugConsole.error(error) + setError(t('generic_something_went_wrong')) + } finally { + setLoading(false) + } + }, + [openDocs, projectSnapshot, t] + ) + + const searchInputRef = useRef(null) + + const handleKeyDown: React.KeyboardEventHandler = useCallback( + event => { + if (event.key === 'Escape') { + setProjectSearchIsOpen(false) + } + }, + [setProjectSearchIsOpen] + ) + + useEventListener( + 'keydown', + useCallback((event: KeyboardEvent) => { + if ( + (isMac ? event.metaKey : event.ctrlKey) && + event.shiftKey && + event.code === 'KeyF' + ) { + searchInputRef.current?.focus() + } + }, []) + ) + + const modifiersRef = useRef<{ setQuery(query: SearchQuery): void }>(null) + + useEventListener( + 'editor:full-project-search', + useCallback((event: CustomEvent) => { + if (modifiersRef.current != null) { + modifiersRef.current.setQuery(event.detail) + } + if (searchInputRef.current != null) { + searchInputRef.current.value = event.detail.search + searchInputRef.current.form?.dispatchEvent( + new Event('submit', { cancelable: true, bubbles: true }) + ) + } + }, []) + ) + + // clear the results when the form is cleared + const handleInput: FormEventHandler = useCallback(event => { + if ( + event instanceof InputEvent && + event.inputType === undefined && + (event.target as HTMLInputElement).value.length === 0 + ) { + setMatchedFiles(undefined) + } + }, []) + + const variableStyle = { + '--font-family': fontFamily, + '--font-size': fontSize, + } as React.CSSProperties + + return ( +
      + {newEditor ? ( + + ) : ( + setProjectSearchIsOpen(false)} + splitTestName="full-project-search" + /> + )} + +
      + + + + + + + + + + + + +
      + + {error && } + +
      + +
      + + {matchedFiles && ( + + )} +
      + ) +} + +export default memo(FullProjectSearchUI) diff --git a/services/web/modules/full-project-search/frontend/js/components/full-project-search.tsx b/services/web/modules/full-project-search/frontend/js/components/full-project-search.tsx new file mode 100644 index 0000000000..7720b21589 --- /dev/null +++ b/services/web/modules/full-project-search/frontend/js/components/full-project-search.tsx @@ -0,0 +1,22 @@ +import React, { FC, lazy, Suspense } from 'react' +import { useLayoutContext } from '@/shared/context/layout-context' +import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils' + +const FullProjectSearchUI = lazy(() => import('./full-project-search-ui')) + +const FullProjectSearch: FC = () => { + const { projectSearchIsOpen } = useLayoutContext() + const newEditor = useIsNewEditorEnabled() + + if (!projectSearchIsOpen && !newEditor) { + return null + } + + return ( + + + + ) +} + +export default FullProjectSearch diff --git a/services/web/modules/full-project-search/frontend/js/components/matched-hit-highlight.tsx b/services/web/modules/full-project-search/frontend/js/components/matched-hit-highlight.tsx new file mode 100644 index 0000000000..8f7122cb22 --- /dev/null +++ b/services/web/modules/full-project-search/frontend/js/components/matched-hit-highlight.tsx @@ -0,0 +1,49 @@ +import React, { FC, useMemo } from 'react' +import { Hit } from '../util/search-snapshot' + +export const MatchedHitHighlight: FC<{ text: string; hit: Hit }> = ({ + text, + hit, +}) => { + const parts = useMemo(() => { + let before = text.substring(0, hit.matchIndex).trimStart() + const match = text.substring(hit.matchIndex, hit.matchIndex + hit.length) + let after = text.substring(hit.matchIndex + hit.length).trimEnd() + + // reduce the prefix to a sensible size before trimming + if (before.length > 250) { + before = before.substring(before.length - 250) + } + + while (before.length > 10) { + const replacement = before.replace(/^\S+\s+/, '') + if (before.length === replacement.length) { + break + } + before = replacement + } + + // reduce the suffix to a sensible size before trimming + if (after.length > 250) { + after = after.substring(0, 250) + } + + while (after.length > 100) { + const replacement = after.replace(/\s+\S+$/, '') + if (after.length === replacement.length) { + break + } + after = replacement + } + + return { before, match, after } + }, [hit, text]) + + return ( + + {parts.before} + {parts.match} + {parts.after} + + ) +} diff --git a/services/web/modules/full-project-search/frontend/js/components/matched-hit.tsx b/services/web/modules/full-project-search/frontend/js/components/matched-hit.tsx new file mode 100644 index 0000000000..2652e589b4 --- /dev/null +++ b/services/web/modules/full-project-search/frontend/js/components/matched-hit.tsx @@ -0,0 +1,45 @@ +import { FC, useCallback, useLayoutEffect, useRef } from 'react' +import { Hit, MatchedFile } from '../util/search-snapshot' +import { MatchedHitHighlight } from './matched-hit-highlight' +import classnames from 'classnames' + +export const MatchedHit: FC<{ + matchedFile: MatchedFile + hit: Hit + selected?: boolean + setSelectedHit(hit?: Hit): void + tabIndex: 0 | -1 +}> = ({ matchedFile, hit, selected = false, setSelectedHit, tabIndex }) => { + const containerRef = useRef(null) + + useLayoutEffect(() => { + if (selected) { + containerRef.current?.focus() + } + }, [selected]) + + const handleSelect: React.MouseEventHandler = useCallback( + event => { + event.preventDefault() + setSelectedHit(hit) + }, + [hit, setSelectedHit] + ) + + return ( + + ) +} diff --git a/services/web/modules/full-project-search/frontend/js/util/regexp.ts b/services/web/modules/full-project-search/frontend/js/util/regexp.ts new file mode 100644 index 0000000000..7795d05911 --- /dev/null +++ b/services/web/modules/full-project-search/frontend/js/util/regexp.ts @@ -0,0 +1,7 @@ +import { SearchQuery } from '@codemirror/search' + +export const createRegExp = (searchQuery: SearchQuery) => { + const flags = 'gmu' + (searchQuery.caseSensitive ? '' : 'i') + + return new RegExp(searchQuery.search, flags) +} diff --git a/services/web/modules/full-project-search/frontend/js/util/search-snapshot.ts b/services/web/modules/full-project-search/frontend/js/util/search-snapshot.ts new file mode 100644 index 0000000000..2abb5626b9 --- /dev/null +++ b/services/web/modules/full-project-search/frontend/js/util/search-snapshot.ts @@ -0,0 +1,92 @@ +import { Text } from '@codemirror/state' +import { RegExpCursor, SearchCursor, SearchQuery } from '@codemirror/search' +import { ProjectSnapshot } from '@/infrastructure/project-snapshot' +import { categorizer, regexpWordTest, stringWordTest } from './search' +import { sendSearchEvent } from '@/features/event-tracking/search-events' + +export type Hit = { + lineIndex: number + matchIndex: number + length: number +} + +export type MatchedFile = { + path: string + lines: string[] + hits: Hit[] +} + +const toLowerCase = (string: string) => string.toLowerCase() + +export const searchSnapshot = async ( + projectSnapshot: ProjectSnapshot, + searchQuery: SearchQuery +) => { + if (!searchQuery.search.trim().length) { + return + } + + const matchedFiles = new Map() + + const createCursor = (text: Text) => { + if (searchQuery.regexp) { + return new RegExpCursor(text, searchQuery.search, { + ignoreCase: !searchQuery.caseSensitive, + test: searchQuery.wholeWord ? regexpWordTest(categorizer) : undefined, + }) + } + + return new SearchCursor( + text, + searchQuery.search, + undefined, + undefined, + searchQuery.caseSensitive ? undefined : toLowerCase, + searchQuery.wholeWord ? stringWordTest(text, categorizer) : undefined + ) + } + + const docPaths = projectSnapshot.getDocPaths() + + for (const path of docPaths) { + const content = projectSnapshot.getDocContents(path) + if (content) { + const lines = content.split('\n') + const text = Text.of(lines) + + const cursor = createCursor(text) + + while (!cursor.next().done) { + const { from, to } = cursor.value + + const matchedFile: MatchedFile = matchedFiles.get(path) ?? { + path, + lines, + hits: [], + } + + const line = text.lineAt(from) + + matchedFile.hits.push({ + lineIndex: line.number - 1, + matchIndex: from - line.from, + length: to - from, + }) + + matchedFiles.set(path, matchedFile) + } + } + } + + const results = [...matchedFiles.values()].sort((a, b) => + a.path.localeCompare(b.path) + ) + + sendSearchEvent('search-execute', { + searchType: 'full-project', + totalDocs: docPaths.length, + totalResults: results.flatMap(file => file.hits).length, + }) + + return results +} diff --git a/services/web/modules/full-project-search/frontend/js/util/search.ts b/services/web/modules/full-project-search/frontend/js/util/search.ts new file mode 100644 index 0000000000..69078f6323 --- /dev/null +++ b/services/web/modules/full-project-search/frontend/js/util/search.ts @@ -0,0 +1,50 @@ +/** + * The functions in this module are from @codemirror/search (MIT license) + * https://github.com/codemirror/search/blob/c1ee7d4b0babd0de0d1198a7c1ece2a387c97c0d/src/search.ts + */ + +import { CharCategory, findClusterBreak, Text } from '@codemirror/state' + +const charBefore = (str: string, index: number) => + str.slice(findClusterBreak(str, index, false), index) + +const charAfter = (str: string, index: number) => + str.slice(index, findClusterBreak(str, index)) + +export const categorizer = (char: string) => { + if (/\s/.test(char)) { + return CharCategory.Space + } + + if (/\w/.test(char)) { + return CharCategory.Word + } + + return CharCategory.Other +} + +export const stringWordTest = + (doc: Text, categorizer: (ch: string) => CharCategory) => + (from: number, to: number, buf: string, bufPos: number) => { + if (bufPos > from || bufPos + buf.length < to) { + bufPos = Math.max(0, from - 2) + buf = doc.sliceString(bufPos, Math.min(doc.length, to + 2)) + } + return ( + (categorizer(charBefore(buf, from - bufPos)) !== CharCategory.Word || + categorizer(charAfter(buf, from - bufPos)) !== CharCategory.Word) && + (categorizer(charAfter(buf, to - bufPos)) !== CharCategory.Word || + categorizer(charBefore(buf, to - bufPos)) !== CharCategory.Word) + ) + } + +export const regexpWordTest = + (categorizer: (ch: string) => CharCategory) => + (_from: number, _to: number, match: RegExpExecArray) => + !match[0].length || + ((categorizer(charBefore(match.input, match.index)) !== CharCategory.Word || + categorizer(charAfter(match.input, match.index)) !== CharCategory.Word) && + (categorizer(charAfter(match.input, match.index + match[0].length)) !== + CharCategory.Word || + categorizer(charBefore(match.input, match.index + match[0].length)) !== + CharCategory.Word)) diff --git a/services/web/modules/full-project-search/frontend/stylesheets/full-project-search.scss b/services/web/modules/full-project-search/frontend/stylesheets/full-project-search.scss new file mode 100644 index 0000000000..b434205a63 --- /dev/null +++ b/services/web/modules/full-project-search/frontend/stylesheets/full-project-search.scss @@ -0,0 +1,356 @@ +@use 'sass:color'; + +.ide-redesign-main { + .full-project-search { + --full-project-search-bg-color: var(--white); + --full-project-search-color: var(--content-primary); + --full-project-search-results-bg-color: var(--bg-light-primary); + --full-project-search-selected-hit-bg-color: var(--bg-accent-03); + --full-project-search-selected-hit-color: var(--green-70); + --matched-hit-highlight-color: var(--yellow-10); + --matched-hit-selected-highlight-color: var(--bg-accent-02); + --matched-hit-selected-unfocused-highlight-color: var(--bg-accent-02); + --collapsible-file-header-count-color: var(--content-primary); + --collapsible-file-header-count-bg-color: var(--bg-light-tertiary); + --search-modifier-checked-bg: var(--bg-accent-03); + --search-modifier-hover-color: var(--bg-light-secondary); + + // Redesign additions + --full-project-search-border-color: var(--border-divider); + --collapsible-file-header-hover-bg: var(--bg-light-secondary); + --matched-hit-highlight-text-color: var(--content-primary); + --matched-file-hit-hover-bg: var(--bg-light-secondary); + --search-modifier-checked-color: var(--green-70); + --search-modifier-color: var(--content-primary); + + &[data-bs-theme='dark'] { + --full-project-search-bg-color: var(--bg-dark-primary); + --full-project-search-color: var(--content-secondary-dark); + --full-project-search-results-bg-color: var(--bg-dark-primary); + --full-project-search-selected-hit-bg-color: var(--green-70); + --full-project-search-selected-hit-color: var(--green-10); + --matched-hit-highlight-color: var(--yellow-70); + --matched-hit-selected-highlight-color: var(--green-70); + --matched-hit-selected-unfocused-highlight-color: var(--content-primary); + --collapsible-file-header-count-color: var(--content-primary); + --collapsible-file-header-count-bg-color: var(--bg-light-tertiary); + --panel-heading-color: var(--content-primary-dark); + --search-modifier-checked-bg: var(--green-70); + --search-modifier-hover-color: var(--bg-dark-secondary); + + // Redesign additions + --full-project-search-border-color: var(--border-divider-dark); + --collapsible-file-header-hover-bg: var(--bg-dark-secondary); + --matched-hit-highlight-text-color: var(--green-10); + --matched-file-hit-hover-bg: var(--bg-dark-secondary); + --search-modifier-checked-color: var(--green-10); + --search-modifier-color: var(--content-primary-dark); + + input[type='search']::selection { + background-color: #b4d1ff; + } + } + + .full-project-search-modifiers { + input:checked ~ .form-check-label { + color: var(--search-modifier-checked-color); + } + + .form-check-label { + color: var(--search-modifier-color); + } + } + + .full-project-search-form { + border-bottom: 1px solid var(--full-project-search-border-color); + } + + .match-counts { + padding: var(--spacing-03) var(--spacing-04); + font-size: var(--font-size-01); + } + + .matched-files { + padding: 0 var(--spacing-02); + } + + .matched-hit-highlight { + color: var(--matched-hit-highlight-text-color); + } + + .matched-file-hits { + width: unset; + overflow: unset; + margin-left: var(--spacing-08); + position: relative; + + &::before { + content: ''; + position: absolute; + left: calc(var(--spacing-04) * -1); + top: 0; + width: 1px; + height: 100%; + background-color: var(--full-project-search-border-color); + border-radius: 0; + } + } + + .matched-file-hit { + border-radius: var(--border-radius-base); + + &:hover { + background-color: var(--matched-file-hit-hover-bg); + } + + &.matched-file-hit-selected:hover { + background-color: var(--full-project-search-selected-hit-bg-color); + color: var(--full-project-search-selected-hit-color); + } + } + + .collapsible-file-header { + border-radius: var(--border-radius-base); + + &:hover { + background-color: var(--collapsible-file-header-hover-bg); + } + } + } +} + +.full-project-search { + --full-project-search-bg-color: var(--bg-light-secondary); + --full-project-search-color: var(--content-secondary); + --full-project-search-results-bg-color: var(--bg-light-primary); + --full-project-search-selected-hit-bg-color: var(--bg-light-tertiary); + --full-project-search-selected-hit-color: var(--content-primary-light); + --matched-hit-highlight-color: var(--bg-light-tertiary); + --matched-hit-selected-highlight-color: var(--bg-accent-02); + --matched-hit-selected-unfocused-highlight-color: var(--bg-accent-02); + --collapsible-file-header-count-color: var(--bs-body-color); + --collapsible-file-header-count-bg-color: var(--bg-light-secondary); + --search-modifier-checked-bg: var(--neutral-30); + --search-modifier-hover-color: var(--neutral-20); + + &[data-bs-theme='dark'] { + --full-project-search-bg-color: var(--bg-dark-secondary); + --full-project-search-color: var(--content-secondary-dark); + --full-project-search-results-bg-color: var(--bg-dark-tertiary); + --full-project-search-selected-hit-bg-color: var(--bg-dark-secondary); + --full-project-search-selected-hit-color: var(--content-primary-dark); + --matched-hit-highlight-color: var(--bg-dark-secondary); + --matched-hit-selected-highlight-color: var(--bg-accent-02); + --matched-hit-selected-unfocused-highlight-color: var(--bg-dark-primary); + --collapsible-file-header-count-color: var(--bs-body-color); + --collapsible-file-header-count-bg-color: var(--bg-dark-secondary); + --panel-heading-color: var(--content-primary-dark); + --search-modifier-checked-bg: var(--neutral-90); + --search-modifier-hover-color: var(--neutral-70); + + input[type='search']::selection { + background-color: #b4d1ff; + } + + .full-project-search-modifiers { + .form-check-label { + color: inherit; + } + } + + .panel-heading-close-button { + color: var(--content-primary-dark); + + &:hover, + &:focus { + color: var(--content-primary); + } + } + } + + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + overflow: hidden; + z-index: 2; + background-color: var(--full-project-search-bg-color); + color: var(--full-project-search-color); + + .full-project-search-heading { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--spacing-02); + font-weight: bold; + } + + .panel-heading-label { + margin-left: var(--spacing-02); + } + + .full-project-search-form { + padding: 8px; + flex-shrink: 0; + } + + .form-check { + padding-left: 0; + } + + .form-check-inline { + display: inline-flex; + gap: var(--spacing-02); + margin-right: 0; + } + + .full-project-search-modifiers { + display: flex; + align-items: center; + gap: var(--spacing-03); + margin-top: var(--spacing-02); + + input[type='checkbox'] { + position: relative; + left: -999px; + } + + .form-check-label { + border-radius: 50%; + cursor: pointer; + display: inline-flex; + width: 2em; + height: 2em; + align-items: center; + justify-content: center; + font-size: 90%; + + &:hover, + &:focus { + background-color: var(--search-modifier-hover-color); + } + } + + input:focus-visible ~ .form-check-label { + box-shadow: 0 0 0 2px var(--border-active-dark); + } + + input:checked ~ .form-check-label { + background-color: var(--search-modifier-checked-bg); + } + } + + .match-counts { + font-size: 80%; + padding: 0 var(--spacing-04) var(--spacing-04); + } + + .matched-files { + flex: 1; + overflow: auto; + font-size: 14px; + background-color: var(--full-project-search-results-bg-color); + } + + .matched-file { + margin-bottom: 8px; + + .collapsible-file-header:focus-visible { + box-shadow: 0 0 0 2px var(--border-active-dark); + } + } + + .collapsible-file-header { + position: sticky; + top: 0; + z-index: 1; + background-color: var(--full-project-search-results-bg-color); + color: inherit; + } + + .collapsible-file-header-count { + background-color: var(--collapsible-file-header-count-bg-color); + color: var(--collapsible-file-header-count-color); + } + + .matched-file-hits { + width: 100%; + overflow: hidden; + border-radius: 0; + } + + .matched-line-number { + white-space: nowrap; + margin-right: 8px; + color: #aaa; + } + + .matched-file-hit { + border: none; + cursor: pointer; + align-items: flex-start; + padding: var(--spacing-02) var(--spacing-05); + word-break: break-all; + white-space: nowrap; + text-align: left; + font-family: var(--font-family); + font-size: var(--font-size); + min-height: fit-content; + border-radius: 0; + background-color: var(--full-project-search-results-bg-color); + + &:hover { + background-color: var(--full-project-search-bg-color); + } + + &.matched-file-hit-highlighted { + background-color: var(--full-project-search-bg-color); + } + } + + .matched-hit-snippet { + overflow-wrap: break-word; + overflow: hidden; + } + + .notification { + margin: var(--spacing-02) var(--spacing-04) var(--spacing-06); + width: auto; + align-items: center; + padding: 0 var(--spacing-04); + + .notification-content { + padding: var(--spacing-02) 0; + } + + .notification-icon { + padding: var(--spacing-02) var(--spacing-04) 0 0; + } + } + + .matched-hit-highlight { + background-color: var(--matched-hit-highlight-color); + border-radius: 4px; + overflow-wrap: normal; + } + + .matched-file-hit-selected { + background-color: var(--full-project-search-selected-hit-bg-color); + color: var(--full-project-search-selected-hit-color); + + .matched-hit-highlight { + background-color: var(--matched-hit-unfocused-highlight-color); + } + } + + /* &:focus-within { + .matched-file-hit-selected { + background-color: var(--bg-accent-01); + color: var(--bg-light-primary); + + .matched-hit-highlight { + background-color: var(--matched-hit-selected-highlight-color); + } + } + } */ +} diff --git a/services/web/modules/full-project-search/stories/full-project-search.stories.tsx b/services/web/modules/full-project-search/stories/full-project-search.stories.tsx new file mode 100644 index 0000000000..e599fabecd --- /dev/null +++ b/services/web/modules/full-project-search/stories/full-project-search.stories.tsx @@ -0,0 +1,113 @@ +import { Meta, StoryObj } from '@storybook/react' +import { ScopeDecorator } from '../../../frontend/stories/decorators/scope' +import useFetchMock from '../../../frontend/stories/hooks/use-fetch-mock' +import FullProjectSearchUI from '../frontend/js/components/full-project-search-ui' + +const meta = { + title: 'Editor / Full Project Search', + component: FullProjectSearchUI, + decorators: [ + Story => ScopeDecorator(Story, { mockCompileOnLoad: true }), + Story => { + useFetchMock(fetchMock => { + fetchMock.post('express:/project/:projectId/flush', { status: 204 }) + + fetchMock.get('express:/project/:projectId/latest/history', { + status: 200, + body: { + chunk: { + history: { + snapshot: { + files: {}, + }, + changes: [ + { + operations: [ + { + pathname: 'main.tex', + file: { + hash: '5199b66d9d1226551be436c66bad9d962cc05537', + stringLength: 7066, + }, + }, + ], + timestamp: '2025-01-03T10:10:40.840Z', + authors: [], + v2Authors: ['66e040e0da7136ec75ffe8a3'], + projectVersion: '1.0', + }, + { + operations: [ + { + pathname: 'sample.bib', + file: { + hash: 'a0e21c740cf81e868f158e30e88985b5ea1d6c19', + stringLength: 244, + }, + }, + ], + timestamp: '2025-01-03T10:10:40.856Z', + authors: [], + v2Authors: ['66e040e0da7136ec75ffe8a3'], + projectVersion: '2.0', + }, + { + operations: [ + { + pathname: 'frog.jpg', + file: { + hash: '5b889ef3cf71c83a4c027c4e4dc3d1a106b27809', + byteLength: 97080, + }, + }, + ], + timestamp: '2025-01-03T10:10:40.890Z', + authors: [], + v2Authors: ['66e040e0da7136ec75ffe8a3'], + projectVersion: '3.0', + }, + ], + }, + startVersion: 0, + }, + }, + }) + + fetchMock.get('express:/project/:projectId/changes', { + status: 200, + body: [], + }) + + fetchMock.get( + 'express:/project/:projectId/blob/5199b66d9d1226551be436c66bad9d962cc05537', + { + status: 200, + body: `Simply use the section and subsection commands, as in this example document! With Overleaf, all the formatting and numbering is handled automatically according to the template you've chosen. If you're using the Visual Editor, you can also create new section and subsections via the buttons in the editor toolbar.`, + } + ) + + fetchMock.get( + 'express:/project/:projectId/blob/a0e21c740cf81e868f158e30e88985b5ea1d6c19', + { + status: 200, + body: `@article{greenwade93, + author = "George D. Greenwade", + title = "The {C}omprehensive {T}ex {A}rchive {N}etwork ({CTAN})", + year = "1993", + journal = "TUGBoat", + volume = "14", + number = "3", + pages = "342--351" +}`, + } + ) + }) + return + }, + ], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const UI = {} satisfies Story diff --git a/services/web/modules/launchpad/app/views/launchpad.pug b/services/web/modules/launchpad/app/views/launchpad.pug index fdf0576c4a..ff917eeb74 100644 --- a/services/web/modules/launchpad/app/views/launchpad.pug +++ b/services/web/modules/launchpad/app/views/launchpad.pug @@ -2,39 +2,43 @@ extends ../../../../app/views/layout-marketing mixin launchpad-check(section) div(data-ol-launchpad-check=section) - span(data-ol-inflight="pending") + span(data-ol-inflight='pending') i.fa.fa-fw.fa-spinner.fa-spin span  #{translate('checking')} - - span(hidden data-ol-inflight="idle") - div(data-ol-result="success") + + span(hidden data-ol-inflight='idle') + div(data-ol-result='success') i.fa.fa-check span  #{translate('ok')} button.btn.btn-inline-link span.text-danger  #{translate('retry')} - div(hidden data-ol-result="error") + div(hidden data-ol-result='error') i.fa.fa-exclamation span  #{translate('error')} button.btn.btn-inline-link span.text-danger  #{translate('retry')} - div.alert.alert-danger + .alert.alert-danger span(data-ol-error) block entrypointVar - entrypoint = 'modules/launchpad/pages/launchpad' - + block vars - metadata = metadata || {} - bootstrap5PageStatus = 'disabled' block append meta - meta(name="ol-adminUserExists" data-type="boolean" content=adminUserExists) - meta(name="ol-ideJsPath" content=buildJsPath('ide.js')) + meta(name='ol-adminUserExists' data-type='boolean' content=adminUserExists) + meta(name='ol-ideJsPath' content=buildJsPath('ide.js')) block content - script(type="text/javascript", nonce=scriptNonce, src=(wsUrl || '/socket.io') + '/socket.io.js') + script( + type='text/javascript' + nonce=scriptNonce + src=(wsUrl || '/socket.io') + '/socket.io.js' + ) - .content.content-alt#main-content + #main-content.content.content-alt .container .row .col-md-8.col-md-offset-2 @@ -49,8 +53,6 @@ block content .row .col-md-8.col-md-offset-2 - - if !adminUserExists .row(data-ol-not-sent) @@ -62,37 +64,34 @@ block content form( data-ol-async-form data-ol-register-admin - action="/launchpad/register_admin" - method="POST" + action='/launchpad/register_admin' + method='POST' ) - input(name='_csrf', type='hidden', value=csrfToken) - +formMessages() + input(name='_csrf' type='hidden' value=csrfToken) + +formMessages .form-group label(for='email') #{translate("email")} input.form-control( - type='email', - name='email', - placeholder="email@example.com" - autocomplete="username" - required, - autofocus="true" + name='email' + type='email' + placeholder='email@example.com' + autocomplete='username' + required + autofocus='true' ) .form-group label(for='password') #{translate("password")} - input.form-control#passwordField( - type='password', - name='password', - placeholder="********", - autocomplete="new-password" - required, + input#passwordField.form-control( + name='password' + type='password' + placeholder='********' + autocomplete='new-password' + required ) .actions - button.btn-primary.btn( - type='submit' - data-ol-disabled-inflight - ) - span(data-ol-inflight="idle") #{translate("register")} - span(hidden data-ol-inflight="pending") #{translate("registering")}… + button.btn-primary.btn(type='submit' data-ol-disabled-inflight) + span(data-ol-inflight='idle') #{translate("register")} + span(hidden data-ol-inflight='pending') #{translate("registering")}… // Ldap Form if authMethod === 'ldap' @@ -103,28 +102,25 @@ block content form( data-ol-async-form data-ol-register-admin - action="/launchpad/register_ldap_admin" - method="POST" + action='/launchpad/register_ldap_admin' + method='POST' ) - input(name='_csrf', type='hidden', value=csrfToken) - +formMessages() + input(name='_csrf' type='hidden' value=csrfToken) + +formMessages .form-group label(for='email') #{translate("email")} input.form-control( - type='email', - name='email', - placeholder="email@example.com" - autocomplete="username" - required, - autofocus="true" + name='email' + type='email' + placeholder='email@example.com' + autocomplete='username' + required + autofocus='true' ) .actions - button.btn-primary.btn( - type='submit' - data-ol-disabled-inflight - ) - span(data-ol-inflight="idle") #{translate("register")} - span(hidden data-ol-inflight="pending") #{translate("registering")}… + button.btn-primary.btn(type='submit' data-ol-disabled-inflight) + span(data-ol-inflight='idle') #{translate("register")} + span(hidden data-ol-inflight='pending') #{translate("registering")}… // Saml Form if authMethod === 'saml' @@ -135,28 +131,25 @@ block content form( data-ol-async-form data-ol-register-admin - action="/launchpad/register_saml_admin" - method="POST" + action='/launchpad/register_saml_admin' + method='POST' ) - input(name='_csrf', type='hidden', value=csrfToken) - +formMessages() + input(name='_csrf' type='hidden' value=csrfToken) + +formMessages .form-group label(for='email') #{translate("email")} input.form-control( - type='email', - name='email', - placeholder="email@example.com" - autocomplete="username" - required, - autofocus="true" + name='email' + type='email' + placeholder='email@example.com' + autocomplete='username' + required + autofocus='true' ) .actions - button.btn-primary.btn( - type='submit' - data-ol-disabled-inflight - ) - span(data-ol-inflight="idle") #{translate("register")} - span(hidden data-ol-inflight="pending") #{translate("registering")}… + button.btn-primary.btn(type='submit' data-ol-disabled-inflight) + span(data-ol-inflight='idle') #{translate("register")} + span(hidden data-ol-inflight='pending') #{translate("registering")}… br @@ -164,7 +157,6 @@ block content if adminUserExists .row .col-md-12.status-indicators - h2 #{translate('status_checks')} @@ -185,42 +177,31 @@ block content h3 #{translate('send_test_email')} form.form( data-ol-async-form - action="/launchpad/send_test_email" - method="POST" + action='/launchpad/send_test_email' + method='POST' ) .form-group - label(for="email") Email - input.form-control( - type="text" - id="email" - name="email" - required - ) - button.btn-primary.btn( - type='submit' - data-ol-disabled-inflight - ) - span(data-ol-inflight="idle") #{translate("send")} - span(hidden data-ol-inflight="pending") #{translate("sending")}… + label(for='email') Email + input.form-control(name='email' type='text' id='email' required) + button.btn-primary.btn(type='submit' data-ol-disabled-inflight) + span(data-ol-inflight='idle') #{translate("send")} + span(hidden data-ol-inflight='pending') #{translate("sending")}… p - +formMessages() - - + +formMessages hr.thin - .row .col-md-12 .text-center br p - a(href="/admin").btn.btn-info + a.btn.btn-info(href='/admin') | Go To Admin Panel |   - a(href="/project").btn.btn-primary + a.btn.btn-primary(href='/project') | Start Using #{settings.appName} br diff --git a/services/web/modules/launchpad/test/unit/src/LaunchpadController.test.mjs b/services/web/modules/launchpad/test/unit/src/LaunchpadController.test.mjs index e34a3583b0..a92fca04de 100644 --- a/services/web/modules/launchpad/test/unit/src/LaunchpadController.test.mjs +++ b/services/web/modules/launchpad/test/unit/src/LaunchpadController.test.mjs @@ -281,8 +281,8 @@ describe('LaunchpadController', function () { .rejects(new Error('woops')) }) - it('should call next with an error', function (ctx) { - return new Promise(resolve => { + it('should call next with an error', async function (ctx) { + await new Promise(resolve => { ctx.next = sinon.stub().callsFake(err => { expect(err).to.be.instanceof(Error) ctx.next.callCount.should.equal(1) diff --git a/services/web/modules/sandboxed-compiles/index.mjs b/services/web/modules/sandboxed-compiles/index.mjs new file mode 100644 index 0000000000..d494a3eec4 --- /dev/null +++ b/services/web/modules/sandboxed-compiles/index.mjs @@ -0,0 +1,22 @@ +import Settings from '@overleaf/settings' + +const parseTextExtensions = function (extensions) { + if (extensions) { + return extensions.split(',').map(ext => ext.trim()) + } else { + return [] + } +} + +if (process.env.SANDBOXED_COMPILES === 'true') { + Settings.allowedImageNames = parseTextExtensions(process.env.ALL_TEX_LIVE_DOCKER_IMAGES) + .map((imageName, index) => ({ + imageName, + imageDesc: parseTextExtensions(process.env.ALL_TEX_LIVE_DOCKER_IMAGE_NAMES)[index] + || imageName.split(':')[1], + })) + if(!process.env.TEX_LIVE_DOCKER_IMAGE) { + process.env.TEX_LIVE_DOCKER_IMAGE = Settings.allowedImageNames[0].imageName + } + Settings.currentImageName = process.env.TEX_LIVE_DOCKER_IMAGE +} diff --git a/services/web/modules/server-ce-scripts/index.js b/services/web/modules/server-ce-scripts/index.mjs similarity index 75% rename from services/web/modules/server-ce-scripts/index.js rename to services/web/modules/server-ce-scripts/index.mjs index 28c3d81419..e6df0f30af 100644 --- a/services/web/modules/server-ce-scripts/index.js +++ b/services/web/modules/server-ce-scripts/index.mjs @@ -3,4 +3,4 @@ /** @type {WebModule} */ const ServerCeScriptsModule = {} -module.exports = ServerCeScriptsModule +export default ServerCeScriptsModule diff --git a/services/web/modules/server-ce-scripts/scripts/check-mongodb.mjs b/services/web/modules/server-ce-scripts/scripts/check-mongodb.mjs index 29f5e7ffd2..46be91a1d9 100644 --- a/services/web/modules/server-ce-scripts/scripts/check-mongodb.mjs +++ b/services/web/modules/server-ce-scripts/scripts/check-mongodb.mjs @@ -9,6 +9,34 @@ const { ObjectId } = mongodb const MIN_MONGO_VERSION = [6, 0] const MIN_MONGO_FEATURE_COMPATIBILITY_VERSION = [6, 0] +// Allow ignoring admin check failures via an environment variable +const OVERRIDE_ENV_VAR_NAME = 'ALLOW_MONGO_ADMIN_CHECK_FAILURES' + +function shouldSkipAdminChecks() { + return process.env[OVERRIDE_ENV_VAR_NAME] === 'true' +} + +function handleUnauthorizedError(err, feature) { + if ( + err instanceof mongodb.MongoServerError && + err.codeName === 'Unauthorized' + ) { + console.warn(`Warning: failed to check ${feature} (not authorised)`) + if (!shouldSkipAdminChecks()) { + console.error( + `Please ensure the MongoDB user has the required admin permissions, or\n` + + `set the environment variable ${OVERRIDE_ENV_VAR_NAME}=true to ignore this check.` + ) + process.exit(1) + } + console.warn( + `Ignoring ${feature} check failure (${OVERRIDE_ENV_VAR_NAME}=${process.env[OVERRIDE_ENV_VAR_NAME]})` + ) + } else { + throw err + } +} + async function main() { let mongoClient try { @@ -18,8 +46,16 @@ async function main() { throw err } - await checkMongoVersion(mongoClient) - await checkFeatureCompatibilityVersion(mongoClient) + try { + await checkMongoVersion(mongoClient) + } catch (err) { + handleUnauthorizedError(err, 'MongoDB version') + } + try { + await checkFeatureCompatibilityVersion(mongoClient) + } catch (err) { + handleUnauthorizedError(err, 'MongoDB feature compatibility version') + } try { await testTransactions(mongoClient) diff --git a/services/web/modules/user-activate/app/views/user/activate.pug b/services/web/modules/user-activate/app/views/user/activate.pug index deebe0b08a..82671f90a7 100644 --- a/services/web/modules/user-activate/app/views/user/activate.pug +++ b/services/web/modules/user-activate/app/views/user/activate.pug @@ -6,14 +6,14 @@ block vars include ../../../../../app/views/_mixins/material_symbol block content - main.content.content-alt#main-content + main#main-content.content.content-alt .container - div.col-lg-6.col-xl-4.m-auto + .col-lg-6.col-xl-4.m-auto .notification-list - .notification.notification-type-success(aria-live="off" role="alert") + .notification.notification-type-success(aria-live='off' role='alert') .notification-content-and-cta .notification-icon - +material-symbol("check_circle") + +material-symbol('check_circle') .notification-content p | #{translate("nearly_activated")} @@ -21,12 +21,12 @@ block content h1.h3 #{translate("please_set_a_password")} form( + name='activationForm' data-ol-async-form - name="activationForm", - action="/user/password/set", - method="POST", + action='/user/password/set' + method='POST' ) - +formMessages() + +formMessages +customFormMessage('token-expired', 'danger') | #{translate("activation_token_expired")} @@ -34,43 +34,39 @@ block content +customFormMessage('invalid-password', 'danger') | #{translate('invalid_password')} - input(name='_csrf', type='hidden', value=csrfToken) - input( - type="hidden", - name="passwordResetToken", - value=token - ) + input(name='_csrf' type='hidden' value=csrfToken) + input(name='passwordResetToken' type='hidden' value=token) .form-group label(for='emailField') #{translate("email")} - input.form-control#emailField( - aria-label="email", - type='email', - name='email', - placeholder="email@example.com", - autocomplete="username" + input#emailField.form-control( + name='email' + aria-label='email' + type='email' + placeholder='email@example.com' + autocomplete='username' value=email - required, + required disabled ) .form-group label(for='passwordField') #{translate("password")} - input.form-control#passwordField( - type='password', - name='password', - placeholder="********", - autocomplete="new-password", - autofocus, - required, + input#passwordField.form-control( + name='password' + type='password' + placeholder='********' + autocomplete='new-password' + autofocus + required minlength=settings.passwordStrengthOptions.length.min ) .actions button.btn.btn-primary( - type='submit', + type='submit' data-ol-disabled-inflight aria-label=translate('activate') ) - span(data-ol-inflight="idle") + span(data-ol-inflight='idle') | #{translate('activate')} - span(hidden data-ol-inflight="pending") + span(hidden data-ol-inflight='pending') | #{translate('activating')}… diff --git a/services/web/modules/user-activate/app/views/user/register.pug b/services/web/modules/user-activate/app/views/user/register.pug index 0f3e5f2f91..27e6f8215c 100644 --- a/services/web/modules/user-activate/app/views/user/register.pug +++ b/services/web/modules/user-activate/app/views/user/register.pug @@ -4,9 +4,9 @@ block entrypointVar - entrypoint = 'modules/user-activate/pages/user-activate-page' block append meta - meta(name="ol-user" data-type="json" content=user) + meta(name='ol-user' data-type='json' content=user) block content - .content.content-alt#main-content + #main-content.content.content-alt .container #user-activate-register-container diff --git a/services/web/modules/user-activate/frontend/js/components/register-form.jsx b/services/web/modules/user-activate/frontend/js/components/register-form.jsx index 7008781631..ec4a91f7e5 100644 --- a/services/web/modules/user-activate/frontend/js/components/register-form.jsx +++ b/services/web/modules/user-activate/frontend/js/components/register-form.jsx @@ -63,7 +63,7 @@ function RegisterForm({ aria-label="emails to register" aria-describedby="input-details" /> -

      +

      Enter the emails you would like to register and separate them using commas

      diff --git a/services/web/modules/user-activate/test/unit/src/UserActivateController.test.mjs b/services/web/modules/user-activate/test/unit/src/UserActivateController.test.mjs index 9019e525d7..be59eecf97 100644 --- a/services/web/modules/user-activate/test/unit/src/UserActivateController.test.mjs +++ b/services/web/modules/user-activate/test/unit/src/UserActivateController.test.mjs @@ -77,38 +77,39 @@ describe('UserActivateController', function () { it('should 404 without a user_id', async function (ctx) { delete ctx.req.query.user_id - return new Promise(resolve => { + + await new Promise(resolve => { ctx.ErrorController.notFound = () => resolve() ctx.UserActivateController.activateAccountPage(ctx.req, ctx.res) }) }) - it('should 404 without a token', function (ctx) { - return new Promise(resolve => { + it('should 404 without a token', async function (ctx) { + await new Promise(resolve => { delete ctx.req.query.token ctx.ErrorController.notFound = resolve ctx.UserActivateController.activateAccountPage(ctx.req, ctx.res) }) }) - it('should 404 without a valid user_id', function (ctx) { - return new Promise(resolve => { + it('should 404 without a valid user_id', async function (ctx) { + await new Promise(resolve => { ctx.UserGetter.promises.getUser = sinon.stub().resolves(null) ctx.ErrorController.notFound = resolve ctx.UserActivateController.activateAccountPage(ctx.req, ctx.res) }) }) - it('should 403 for complex user_id', function (ctx) { - return new Promise(resolve => { + it('should 403 for complex user_id', async function (ctx) { + await new Promise(resolve => { ctx.ErrorController.forbidden = resolve ctx.req.query.user_id = { first_name: 'X' } ctx.UserActivateController.activateAccountPage(ctx.req, ctx.res) }) }) - it('should redirect activated users to login', function (ctx) { - return new Promise(resolve => { + it('should redirect activated users to login', async function (ctx) { + await new Promise(resolve => { ctx.user.loginCount = 1 ctx.res.redirect = url => { sinon.assert.calledWith(ctx.UserGetter.promises.getUser, ctx.user_id) @@ -119,8 +120,8 @@ describe('UserActivateController', function () { }) }) - it('render the activation page if the user has not logged in before', function (ctx) { - return new Promise(resolve => { + it('render the activation page if the user has not logged in before', async function (ctx) { + await new Promise(resolve => { ctx.user.loginCount = 0 ctx.res.render = (page, opts) => { page.should.equal(VIEW_PATH) diff --git a/services/web/package.json b/services/web/package.json index 59825e0e68..b0cee1af06 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -28,6 +28,8 @@ "format:fix": "prettier --write $PWD/'**/*.{js,jsx,mjs,ts,tsx,json}'", "format:styles": "prettier --list-different $PWD/'**/*.{css,less,scss}'", "format:styles:fix": "prettier --write $PWD/'**/*.{css,less,scss}'", + "format:pug": "prettier --list-different $PWD/'**/*.pug'", + "format:pug:fix": "prettier --write $PWD/'**/*.pug'", "lint": "eslint --max-warnings 0 --format unix --ext .js,.jsx,.mjs,.ts,.tsx .", "lint:fix": "eslint --fix --ext .js,.jsx,.mjs,.ts,.tsx .", "lint:styles": "stylelint '**/*.scss'", @@ -89,6 +91,7 @@ "@overleaf/promise-utils": "*", "@overleaf/redis-wrapper": "*", "@overleaf/settings": "*", + "@overleaf/stream-utils": "*", "@phosphor-icons/react": "^2.1.7", "@slack/webhook": "^7.0.2", "@stripe/stripe-js": "^7.3.0", @@ -209,6 +212,7 @@ "@pollyjs/adapter-node-http": "^6.0.6", "@pollyjs/core": "^6.0.6", "@pollyjs/persister-fs": "^6.0.6", + "@prettier/plugin-pug": "^3.4.0", "@replit/codemirror-emacs": "overleaf/codemirror-emacs#4394c03858f27053f8768258e9493866e06e938e", "@replit/codemirror-indentation-markers": "overleaf/codemirror-indentation-markers#78264032eb286bc47871569ae87bff5ca1c6c161", "@replit/codemirror-vim": "overleaf/codemirror-vim#1bef138382d948018f3f9b8a4d7a70ab61774e4b", @@ -237,7 +241,6 @@ "@types/mocha": "^9.1.0", "@types/mocha-each": "^2.0.0", "@types/react": "^18.3.20", - "@types/react-bootstrap": "^0.32.37", "@types/react-color": "^3.0.13", "@types/react-dom": "^18.3.6", "@types/react-google-recaptcha": "^2.1.9", @@ -275,6 +278,7 @@ "chartjs-plugin-datalabels": "^2.2.0", "cheerio": "^1.0.0-rc.3", "classnames": "^2.2.6", + "confusing-browser-globals": "^1.0.11", "cookie-signature": "^1.2.1", "copy-webpack-plugin": "^11.0.0", "core-js": "^3.41.0", @@ -301,6 +305,7 @@ "formik": "^2.2.9", "fuse.js": "^3.0.0", "glob": "^7.1.6", + "globals": "^16.2.0", "handlebars": "^4.7.8", "handlebars-loader": "^1.7.3", "html-webpack-plugin": "^5.5.3", @@ -357,7 +362,7 @@ "timekeeper": "^2.2.0", "to-string-loader": "^1.2.0", "tty-browserify": "^0.0.1", - "typescript": "^5.0.4", + "typescript": "^5.8.3", "uuid": "^9.0.1", "vitest": "^3.1.2", "w3c-keyname": "^2.2.8", diff --git a/services/web/public/favicon-compiled.svg b/services/web/public/favicon-compiled.svg new file mode 100644 index 0000000000..8bee787bb2 --- /dev/null +++ b/services/web/public/favicon-compiled.svg @@ -0,0 +1,9 @@ + + + + + + diff --git a/services/web/public/favicon-compiling.svg b/services/web/public/favicon-compiling.svg new file mode 100644 index 0000000000..fed675637c --- /dev/null +++ b/services/web/public/favicon-compiling.svg @@ -0,0 +1,9 @@ + + + + + + diff --git a/services/web/public/favicon-error.svg b/services/web/public/favicon-error.svg new file mode 100644 index 0000000000..5b88401356 --- /dev/null +++ b/services/web/public/favicon-error.svg @@ -0,0 +1,9 @@ + + + + + + diff --git a/services/web/scripts/check_docs.mjs b/services/web/scripts/check_docs.mjs index 699738f75c..3b7e17128e 100644 --- a/services/web/scripts/check_docs.mjs +++ b/services/web/scripts/check_docs.mjs @@ -16,7 +16,7 @@ const OPTS = parseArgs() function parseArgs() { const args = minimist(process.argv.slice(2), { string: ['min-project-id', 'max-project-id', 'project-modified-since'], - boolean: ['help', 'dangling-comments', 'tracked-changes'], + boolean: ['help', 'dangling-comments', 'tracked-changes', 'any-comments'], }) if (args.help) { @@ -26,9 +26,10 @@ function parseArgs() { const danglingComments = Boolean(args['dangling-comments']) const trackedChanges = Boolean(args['tracked-changes']) - if (!danglingComments && !trackedChanges) { + const anyComments = Boolean(args['any-comments']) + if (!danglingComments && !trackedChanges && !anyComments) { console.log( - 'At least one of --dangling-comments or --tracked-changes must be enabled' + 'At least one of --dangling-comments, --tracked-changes, or --any-comments must be enabled' ) process.exit(1) } @@ -41,6 +42,7 @@ function parseArgs() { : null, danglingComments, trackedChanges, + anyComments, concurrency: parseInt(args.concurrency ?? '1', 10), } } @@ -56,6 +58,7 @@ Options: Example: 2020-01-01 --dangling-comments Report projects with dangling comments --tracked-changes Report projects with tracked changes + --any-comments Report projects with any comments --concurrency How many projects can be processed in parallel `) } @@ -65,6 +68,8 @@ async function main() { let projectsProcessed = 0 let danglingCommentsFound = 0 let trackedChangesFound = 0 + let anyCommentsFound = 0 + for await (const projectId of getProjectIds()) { await queue.onEmpty() queue.add(async () => { @@ -87,6 +92,13 @@ async function main() { } } + if (OPTS.anyComments) { + if (docsHaveAnyComments(docs)) { + console.log(`Project ${projectId} has comments`) + anyCommentsFound += 1 + } + } + projectsProcessed += 1 if (projectsProcessed % 100000 === 0) { console.log( @@ -106,6 +118,10 @@ async function main() { if (OPTS.trackedChanges) { console.log(`${trackedChangesFound} projects with tracked changes found`) } + + if (OPTS.anyComments) { + console.log(`${anyCommentsFound} projects with any comments found`) + } } function getProjectIds() { @@ -213,6 +229,16 @@ function docsHaveTrackedChanges(docs) { return false } +function docsHaveAnyComments(docs) { + for (const doc of docs) { + const comments = doc.ranges?.comments ?? [] + if (comments.length > 0) { + return true + } + } + return false +} + try { await scriptRunner(main) process.exit(0) diff --git a/services/web/scripts/check_duplicate_collaborators.mjs b/services/web/scripts/check_duplicate_collaborators.mjs new file mode 100644 index 0000000000..cf4ba7eafd --- /dev/null +++ b/services/web/scripts/check_duplicate_collaborators.mjs @@ -0,0 +1,157 @@ +#!/usr/bin/env node + +/** + * Script to check for and optionally fix duplicate collaborators in projects + * + * A duplicate collaborator is when the same user id appears in multiple collaborator + * arrays for the same project (collaberator_refs, readOnly_refs, reviewer_refs, etc.) + * + * If "--fix" is used, this script will remove users from higher privilege roles and keeps them in lower privilege roles + * + * Usage: + * node scripts/check_duplicate_collaborators.mjs [--fix] [--project-id=] + */ + +import { + batchedUpdate, + READ_PREFERENCE_SECONDARY, +} from '@overleaf/mongo-utils/batchedUpdate.js' +import { db, ObjectId } from '../app/src/infrastructure/mongodb.js' +import minimist from 'minimist' +import { scriptRunner } from './lib/ScriptRunner.mjs' + +const args = minimist(process.argv.slice(2), { + boolean: ['fix'], + string: ['project-id', 'start-date', 'end-date'], + default: { + fix: false, + }, +}) + +async function fixDuplicateCollaborators(project, trackProgress) { + const dryRun = !args.fix + const removeCollaboratorRefs = [] + const removeReviewerRefs = [] + + for (const reviewerRef of project.reviewer_refs || []) { + if (includesId(project.readOnly_refs, reviewerRef)) { + removeReviewerRefs.push(reviewerRef) // remove from reviewer_refs (keep read-only) + } + if (includesId(project.collaberator_refs, reviewerRef)) { + removeCollaboratorRefs.push(reviewerRef) // remove from collaberator_refs (keep reviewer) + } + } + + if ( + !dryRun && + (removeCollaboratorRefs.length > 0 || removeReviewerRefs.length > 0) + ) { + await db.projects.updateOne( + { _id: project._id }, + { + $pull: { + collaberator_refs: { $in: removeCollaboratorRefs }, + reviewer_refs: { $in: removeReviewerRefs }, + }, + } + ) + } + + const action = args.fix ? 'Removed' : 'Found duplicates in' + + if (removeCollaboratorRefs.length > 0) { + trackProgress( + `${action} collaborators from project ${project._id}:`, + removeCollaboratorRefs + ) + } + if (removeReviewerRefs.length > 0) { + trackProgress( + `${action} reviewers from project ${project._id}:`, + removeReviewerRefs + ) + } +} + +async function main(trackProgress) { + if (!args['start-date'] && !args['project-id']) { + console.error( + 'Please provide either --start-date or --project-id argument.' + ) + process.exit(1) + } + + if (args['project-id']) { + const projectId = new ObjectId(args['project-id']) + const project = await db.projects.findOne( + { _id: projectId }, + { + readPreference: READ_PREFERENCE_SECONDARY, + projection: { + _id: 1, + collaberator_refs: 1, + readOnly_refs: 1, + reviewer_refs: 1, + }, + } + ) + + if (!project) { + console.error(`Project with id ${projectId} not found`) + process.exit(1) + } + + await fixDuplicateCollaborators(project, trackProgress) + + return + } + + let projectsProcessed = 0 + await batchedUpdate( + db.projects, + { + reviewer_refs: { $ne: [] }, + $or: [{ readOnly_refs: { $ne: [] } }, { collaberator_refs: { $ne: [] } }], + }, + /** + * @param {Array} projects + * @return {Promise} + */ + async function projects(projects) { + for (const project of projects) { + projectsProcessed += 1 + if (projectsProcessed % 10000 === 0) { + console.log(projectsProcessed, 'projects processed') + } + + await fixDuplicateCollaborators(project, trackProgress) + } + }, + { + _id: 1, + collaberator_refs: 1, + readOnly_refs: 1, + reviewer_refs: 1, + }, + undefined, + { + trackProgress, + BATCH_RANGE_START: new Date(args['start-date']).toISOString(), + BATCH_RANGE_END: args['end-date'] + ? new Date(args['end-date']).toISOString() + : new Date().toISOString(), + } + ) +} + +function includesId(array, id) { + return array?.some(item => item.toString() === id.toString()) +} + +try { + await scriptRunner(main) + process.exit() +} catch (error) { + console.error(error) + process.exit(1) +} diff --git a/services/web/scripts/confirmed_at_to_dates.mjs b/services/web/scripts/confirmed_at_to_dates.mjs deleted file mode 100644 index 60fcc543fd..0000000000 --- a/services/web/scripts/confirmed_at_to_dates.mjs +++ /dev/null @@ -1,53 +0,0 @@ -import { db } from '../app/src/infrastructure/mongodb.js' -import { fileURLToPath } from 'node:url' - -async function updateStringDates() { - const users = await db.users.aggregate([ - { $unwind: { path: '$emails' } }, - { - $match: { 'emails.confirmedAt': { $exists: true, $type: 'string' } }, - }, - { - $project: { - _id: 1, - 'emails.email': 1, - 'emails.confirmedAt': 1, - }, - }, - ]) - - let user - let count = 0 - while ((user = await users.next())) { - count += 1 - if (count % 10000 === 0) { - console.log(`processed ${count} users`) - } - const confirmedAt = user.emails.confirmedAt - const dateConfirmedAt = new Date(confirmedAt.replace(/ UTC$/, '')) - await db.users.updateOne( - { - _id: user._id, - 'emails.email': user.emails.email, - }, - { - $set: { - 'emails.$.confirmedAt': dateConfirmedAt, - }, - } - ) - } - console.log(`Updated ${count} confirmedAt strings to dates!`) -} - -if (fileURLToPath(import.meta.url) === process.argv[1]) { - try { - await updateStringDates() - process.exit(0) - } catch (error) { - console.error(error) - process.exit(1) - } -} - -export default updateStringDates diff --git a/services/web/scripts/convert_archived_state.mjs b/services/web/scripts/convert_archived_state.mjs deleted file mode 100644 index e2c108ed80..0000000000 --- a/services/web/scripts/convert_archived_state.mjs +++ /dev/null @@ -1,93 +0,0 @@ -import _ from 'lodash' -import { db } from '../app/src/infrastructure/mongodb.js' -import { batchedUpdate } from '@overleaf/mongo-utils/batchedUpdate.js' -import { promiseMapWithLimit } from '@overleaf/promise-utils' -import { fileURLToPath } from 'node:url' - -const WRITE_CONCURRENCY = parseInt(process.env.WRITE_CONCURRENCY, 10) || 10 - -// $ node scripts/convert_archived_state.mjs FIRST,SECOND - -async function main(STAGE) { - for (const FIELD of ['archived', 'trashed']) { - if (STAGE.includes('FIRST')) { - await batchedUpdate( - db.projects, - { [FIELD]: false }, - { - $set: { [FIELD]: [] }, - } - ) - - console.error('Done, with first part for field:', FIELD) - } - - if (STAGE.includes('SECOND')) { - await batchedUpdate( - db.projects, - { [FIELD]: true }, - async function performUpdate(nextBatch) { - await promiseMapWithLimit( - WRITE_CONCURRENCY, - nextBatch, - async project => { - try { - await upgradeFieldToArray({ project, FIELD }) - } catch (err) { - console.error(project._id, err) - throw err - } - } - ) - }, - { - _id: 1, - owner_ref: 1, - collaberator_refs: 1, - readOnly_refs: 1, - tokenAccessReadAndWrite_refs: 1, - tokenAccessReadOnly_refs: 1, - } - ) - - console.error('Done, with second part for field:', FIELD) - } - } -} - -async function upgradeFieldToArray({ project, FIELD }) { - return db.projects.updateOne( - { _id: project._id }, - { - $set: { [FIELD]: getAllUserIds(project) }, - } - ) -} - -function getAllUserIds(project) { - return _.unionWith( - [project.owner_ref], - project.collaberator_refs, - project.readOnly_refs, - project.tokenAccessReadAndWrite_refs, - project.tokenAccessReadOnly_refs, - _objectIdEquals - ) -} - -function _objectIdEquals(firstVal, secondVal) { - // For use as a comparator for unionWith - return firstVal.toString() === secondVal.toString() -} - -if (fileURLToPath(import.meta.url) === process.argv[1]) { - try { - await main(process.argv.pop()) - process.exit(0) - } catch (error) { - console.error({ error }) - process.exit(1) - } -} - -export default main diff --git a/services/web/scripts/oauth/upgrade_token_scopes.mjs b/services/web/scripts/oauth/upgrade_token_scopes.mjs new file mode 100644 index 0000000000..f28417c1ec --- /dev/null +++ b/services/web/scripts/oauth/upgrade_token_scopes.mjs @@ -0,0 +1,75 @@ +import minimist from 'minimist' +import { db } from '../../app/src/infrastructure/mongodb.js' + +const OPTS = parseArgs() + +function parseArgs() { + const args = minimist(process.argv.slice(2), { + boolean: ['help', 'commit'], + }) + if (args.help) { + usage() + process.exit(0) + } + + if (args._.length === 0) { + usage() + process.exit(1) + } + + return { + appIds: args._, + commit: args.commit, + } +} + +function usage() { + console.error(`Usage: updgrade_token_scopes.mjs [--commit] APP_ID ... + + This script will upgrade all existing OAuth tokens for the given app(s) so + that their scope matches the scope configured on the app. + + USE WITH CAUTION: any token with limited scope previously issued will be + upgraded to support all scopes available to the app. + `) +} + +async function main() { + for (const appId of OPTS.appIds) { + const app = await db.oauthApplications.findOne({ id: appId }) + if (app == null) { + console.error(`App "${appId}" not found. Skipping.`) + continue + } + + const expectedScope = (app.scopes ?? []).join(' ') + + const filter = { + oauthApplication_id: app._id, + scope: { $ne: expectedScope }, + } + if (OPTS.commit) { + const result = await db.oauthAccessTokens.updateMany(filter, { + $set: { scope: expectedScope }, + }) + console.error( + `App "${appId}": upgraded ${result.modifiedCount} access tokens` + ) + } else { + const count = await db.oauthAccessTokens.count(filter) + console.error(`App "${appId}": would upgrade ${count} access tokens`) + } + } + + if (!OPTS.commit) { + console.error('This was a dry run. Re-run with --commit to apply changes') + } +} + +try { + await main() + process.exit(0) +} catch (error) { + console.error(error) + process.exit(1) +} diff --git a/services/web/scripts/plan-prices/plans.mjs b/services/web/scripts/plan-prices/plans.mjs index 122b2c5683..6d67214d11 100644 --- a/services/web/scripts/plan-prices/plans.mjs +++ b/services/web/scripts/plan-prices/plans.mjs @@ -64,11 +64,6 @@ const currencies = [ 'USD', ] -/** - * This is duplicated in: - * - services/web/app/src/Features/Subscription/SubscriptionHelper.js - * - services/web/modules/subscriptions/frontend/js/pages/plans-new-design/group-member-picker/group-plan-pricing.js - */ function roundUpToNearest5Cents(number) { return Math.ceil(number * 20) / 20 } diff --git a/services/web/scripts/recurly/generate_recurly_prices.mjs b/services/web/scripts/recurly/generate_recurly_prices.mjs index 78cb7e2138..5624cd6f8c 100644 --- a/services/web/scripts/recurly/generate_recurly_prices.mjs +++ b/services/web/scripts/recurly/generate_recurly_prices.mjs @@ -34,8 +34,6 @@ const argv = minimist(process.argv.slice(2), { const CURRENCY_CODE_REGEX = /^[A-Z]{3}$/ // Group plans have a plan code of the form group_name_size_type, e.g. const GROUP_SIZE_REGEX = /group_\w+_([0-9]+)_\w+/ -// Only group plans with more than 4 users can have additional licenses -const SINGLE_LICENSE_MAX_GROUP_SIZE = 4 // Compute prices for the base plan @@ -92,15 +90,13 @@ function transformRecordToPlan(record) { // Large group plans have an add-on for additional licenses if (isGroupPlan(record)) { const size = getGroupSize(record) - if (size > SINGLE_LICENSE_MAX_GROUP_SIZE) { - const addOnPrices = computeAddOnPrices(prices, size) - plan._addOns = [ - { - code: 'additional-license', - currencies: addOnPrices, - }, - ] - } + const addOnPrices = computeAddOnPrices(prices, size) + plan._addOns = [ + { + code: 'additional-license', + currencies: addOnPrices, + }, + ] } return plan } diff --git a/services/web/scripts/recurly/resync_recurly_state_single_subscription.mjs b/services/web/scripts/recurly/resync_recurly_state_single_subscription.mjs new file mode 100644 index 0000000000..85d3e3eb77 --- /dev/null +++ b/services/web/scripts/recurly/resync_recurly_state_single_subscription.mjs @@ -0,0 +1,137 @@ +import { Subscription } from '../../app/src/models/Subscription.js' +import RecurlyWrapper from '../../app/src/Features/Subscription/RecurlyWrapper.js' +import SubscriptionUpdater from '../../app/src/Features/Subscription/SubscriptionUpdater.js' +import minimist from 'minimist' +import { setTimeout } from 'node:timers/promises' +import util from 'node:util' + +util.inspect.defaultOptions.maxArrayLength = null + +const handleSyncSubscriptionError = async (subscription, error) => { + console.warn(`Errors with subscription id=${subscription._id}:`, error) + + if (typeof error === 'string' && error.match(/429$/)) { + console.warn('Recurly rate limit hit (429). Waiting for 5 minutes...') + await setTimeout(1000 * 60 * 5) + return + } + if (typeof error === 'string' && error.match(/5\d\d$/)) { + console.warn('Recurly server error (5xx). Retrying in 1 minute...') + await setTimeout(1000 * 60) + await syncRecurlyStateInSubscription(subscription) + return + } + await setTimeout(80) +} + +const syncRecurlyStateInSubscription = async subscription => { + let recurlySubscription + + try { + recurlySubscription = await RecurlyWrapper.promises.getSubscription( + subscription.recurlySubscription_id + ) + } catch (error) { + await handleSyncSubscriptionError(subscription, error) + return + } + + if (!subscription.recurlyStatus) { + subscription.recurlyStatus = {} + } + + if (subscription.recurlyStatus.state !== recurlySubscription.state) { + console.log( + `Mismatched recurlyStatus.state for subscription ID ${subscription._id}. ` + + `Our database: '${subscription.recurlyStatus.state || 'undefined/null'}', recurly: '${recurlySubscription.state}'.` + ) + + subscription.recurlyStatus.state = recurlySubscription.state + + if (COMMIT) { + try { + console.log( + `Committing update for subscription ID: ${subscription._id}` + ) + await SubscriptionUpdater.promises.updateSubscriptionFromRecurly( + recurlySubscription, + subscription, + {} + ) + } catch (error) { + await handleSyncSubscriptionError(subscription, error) + } + + console.log( + `Successfully updated subscription ID ${subscription._id} with new recurlyStatus.state: ${subscription.recurlyStatus.state}` + ) + } + } else { + console.log( + `Subscription ID ${subscription._id}: recurlyStatus.state is already in sync.` + ) + } + + await setTimeout(80) +} + +let COMMIT, SUBSCRIPTION_ID + +const setup = () => { + const argv = minimist(process.argv.slice(2)) + + SUBSCRIPTION_ID = argv.subscriptionId + if (!SUBSCRIPTION_ID) { + console.error( + 'Error: Please provide a subscription ID using --subscriptionId=' + ) + process.exit(1) + } + console.log( + `Attempting to sync subscription.recurlyStatus with ID: ${SUBSCRIPTION_ID}` + ) + + COMMIT = argv.commit !== undefined + if (!COMMIT) { + console.warn( + 'Doing dry run without --commit. No database changes will be made.' + ) + } +} + +const run = async () => { + try { + const subscription = await Subscription.findById(SUBSCRIPTION_ID).exec() + + if (!subscription) { + console.error( + `Error: Subscription with ID ${SUBSCRIPTION_ID} not found in the database.` + ) + process.exit(1) + } + + if (!subscription.recurlySubscription_id) { + console.error( + `Error: Subscription ID ${SUBSCRIPTION_ID} does not have a Recurly subscription ID.` + ) + process.exit(1) + } + + console.log( + `Found subscription: ${subscription._id}, Recurly ID: ${subscription.recurlySubscription_id}` + ) + + await syncRecurlyStateInSubscription(subscription) + + console.log('DONE') + } catch (error) { + console.error('An unhandled error occurred during script execution:', error) + process.exit(1) + } +} + +setup() + +await run() + +process.exit(0) diff --git a/services/web/scripts/split_tests_assigned_at_to_dates.mjs b/services/web/scripts/split_tests_assigned_at_to_dates.mjs deleted file mode 100644 index ea6fd3fbea..0000000000 --- a/services/web/scripts/split_tests_assigned_at_to_dates.mjs +++ /dev/null @@ -1,46 +0,0 @@ -import { db } from '../app/src/infrastructure/mongodb.js' -import { fileURLToPath } from 'node:url' - -async function updateStringDates() { - const users = db.users.find({ - splitTests: { $exists: true }, - }) - - let user - let count = 0 - while ((user = await users.next())) { - count += 1 - if (count % 10000 === 0) { - console.log(`processed ${count} users...`) - } - - const splitTests = user.splitTests - for (const splitTestKey of Object.keys(splitTests)) { - for (const variantIndex in splitTests[splitTestKey]) { - splitTests[splitTestKey][variantIndex].assignedAt = new Date( - splitTests[splitTestKey][variantIndex].assignedAt - ) - } - } - - await db.users.updateOne( - { - _id: user._id, - }, - { $set: { splitTests } } - ) - } - console.log(`Updated ${count} assignedAt strings to dates!`) -} - -if (fileURLToPath(import.meta.url) === process.argv[1]) { - try { - await updateStringDates() - process.exit(0) - } catch (error) { - console.error(error) - process.exit(1) - } -} - -export default updateStringDates diff --git a/services/web/scripts/translations/Dockerfile b/services/web/scripts/translations/Dockerfile index ada45efb8f..08dc2265c5 100644 --- a/services/web/scripts/translations/Dockerfile +++ b/services/web/scripts/translations/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22.15.1 +FROM node:22.17.0 WORKDIR /app/scripts/translations diff --git a/services/web/test/acceptance/config/settings.test.defaults.js b/services/web/test/acceptance/config/settings.test.defaults.js index b0db7f7449..860b5e2066 100644 --- a/services/web/test/acceptance/config/settings.test.defaults.js +++ b/services/web/test/acceptance/config/settings.test.defaults.js @@ -24,7 +24,7 @@ module.exports = { ? JSON.parse(process.env.ADMIN_DOMAINS) : ['example.com'], - statusPageUrl: 'status.example.com', + statusPageUrl: 'https://status.example.com', cdn: { web: { host: 'cdn.example.com', @@ -199,6 +199,30 @@ module.exports = { price_in_cents: 3000, features: features.professional, }, + { + planCode: 'group_professional', + name: 'Professional - Group Account - Enterprise', + hideFromUsers: true, + price_in_cents: 0, + annual: true, + features: features.professional, + groupPlan: true, + membersLimit: 0, + membersLimitAddOn: 'additional-license', + canUseFlexibleLicensing: true, + }, + { + planCode: 'group_collaborator', + name: 'Collaborator - Group Account - Enterprise', + hideFromUsers: true, + price_in_cents: 0, + annual: true, + features: features.collaborator, + groupPlan: true, + membersLimit: 0, + membersLimitAddOn: 'additional-license', + canUseFlexibleLicensing: true, + }, ], bonus_features: { diff --git a/services/web/test/acceptance/src/AddSecondaryEmailTests.mjs b/services/web/test/acceptance/src/AddSecondaryEmailTests.mjs index 2fdc7705fe..c153416fe6 100644 --- a/services/web/test/acceptance/src/AddSecondaryEmailTests.mjs +++ b/services/web/test/acceptance/src/AddSecondaryEmailTests.mjs @@ -125,7 +125,9 @@ describe('Add secondary email address confirmation code email', function () { }) expect(res.response.statusCode).to.equal(409) - expect(res.body.message.text).to.equal('This email is already registered') + expect(res.body.message.text).to.equal( + 'This email address is already associated with a different Overleaf account.' + ) }) }) diff --git a/services/web/test/acceptance/src/CaptchaTests.mjs b/services/web/test/acceptance/src/CaptchaTests.mjs index 10fdcf1626..223fe02cb0 100644 --- a/services/web/test/acceptance/src/CaptchaTests.mjs +++ b/services/web/test/acceptance/src/CaptchaTests.mjs @@ -104,6 +104,59 @@ describe('Captcha', function () { }) }) + describe('trustedUsersRegex', function () { + let resetTrustedUsersRegex + beforeEach(function () { + resetTrustedUsersRegex = Settings.recaptcha.trustedUsersRegex + Settings.recaptcha.trustedUsersRegex = /\+trusted@example\.com$/ + }) + + afterEach(function () { + Settings.recaptcha.trustedUsersRegex = resetTrustedUsersRegex + }) + + describe('when user is trusted, can login without captcha', function () { + let trustedUser + beforeEach(async function () { + trustedUser = new User({ + email: 'acceptance-test+trusted@example.com', + }) + await trustedUser.ensureUserExists() + }) + + it('should be able to skip captcha', async function () { + expect(await canSkipCaptcha(trustedUser.email)).to.equal(true) + }) + + it('should note that the user is trusted', async function () { + const { response, body } = await login( + trustedUser.email, + trustedUser.password, + '' + ) + expectSuccessfulLogin(response, body) + const auditLog = await trustedUser.getAuditLog() + expect(auditLog[0].info).to.deep.equal({ + captcha: 'trusted', + method: 'Password login', + }) + }) + }) + + describe('when user is not trusted', function () { + it('should not be able to skip captcha', async function () { + expect(await canSkipCaptcha(user.email)).to.equal(false) + }) + + it('should not add an audit log entry for trusted user', async function () { + const { response, body } = await login(user.email, user.password, '') + expectBadCaptchaResponse(response, body) + const auditLog = await user.getAuditLog() + expect(auditLog).to.have.lengthOf(0) + }) + }) + }) + describe('deviceHistory', function () { beforeEach('login', async function () { const { response, body } = await loginWithCaptcha('valid') diff --git a/services/web/test/acceptance/src/ConvertArchivedState.mjs b/services/web/test/acceptance/src/ConvertArchivedState.mjs index 53e2278974..06ce5e3b15 100644 --- a/services/web/test/acceptance/src/ConvertArchivedState.mjs +++ b/services/web/test/acceptance/src/ConvertArchivedState.mjs @@ -120,7 +120,7 @@ describe('ConvertArchivedState', function () { beforeEach(function (done) { exec( - 'CONNECT_DELAY=1 node scripts/convert_archived_state.mjs FIRST,SECOND', + 'east migrate --tag server-ce --force 20221111111111_ce_sp_convert_archived_state', error => { if (error) { return done(error) diff --git a/services/web/test/acceptance/src/ConvertEmailConfirmedAtToDates.js b/services/web/test/acceptance/src/ConvertEmailConfirmedAtToDates.js new file mode 100644 index 0000000000..da5f842bb6 --- /dev/null +++ b/services/web/test/acceptance/src/ConvertEmailConfirmedAtToDates.js @@ -0,0 +1,69 @@ +import { expect } from 'chai' +import { db } from '../../../app/src/infrastructure/mongodb.js' +import { exec } from 'node:child_process' + +describe('ConvertEmailConfirmedAtToDates', function () { + beforeEach('insert data', async function () { + await db.users.insertMany([ + { email: 'foo0@bar.com', emails: [{ email: 'foo0@bar.com' }] }, + { + email: 'foo1@bar.com', + emails: [ + { email: 'foo1@bar.com', confirmedAt: '2025-06-20 15:53:31 UTC' }, + ], + }, + { + email: 'foo2@bar.com', + emails: [ + { email: 'foo2@bar.com', confirmedAt: '2025-06-20 15:53:32 UTC' }, + { email: 'foo3@bar.com', confirmedAt: '2025-06-20 15:53:33 UTC' }, + { + email: 'foo4@bar.com', + confirmedAt: new Date('2025-06-20T15:53:31.134Z'), + }, + ], + }, + ]) + }) + + beforeEach('run migration', function (done) { + exec( + 'east migrate -t saas --force 20210726083523_convert_confirmedAt_strings_to_dates', + done + ) + }) + + it('should update the dates', async function () { + expect( + await db.users.find({}, { projection: { _id: 0 } }).toArray() + ).to.deep.equal([ + { email: 'foo0@bar.com', emails: [{ email: 'foo0@bar.com' }] }, + { + email: 'foo1@bar.com', + emails: [ + { + email: 'foo1@bar.com', + confirmedAt: new Date('2025-06-20T15:53:31.000Z'), + }, + ], + }, + { + email: 'foo2@bar.com', + emails: [ + { + email: 'foo2@bar.com', + confirmedAt: new Date('2025-06-20T15:53:32.000Z'), + }, + { + email: 'foo3@bar.com', + confirmedAt: new Date('2025-06-20T15:53:33.000Z'), + }, + { + email: 'foo4@bar.com', + confirmedAt: new Date('2025-06-20T15:53:31.134Z'), + }, + ], + }, + ]) + }) +}) diff --git a/services/web/test/acceptance/src/ConvertSplitTestAssignedAtToDates.js b/services/web/test/acceptance/src/ConvertSplitTestAssignedAtToDates.js new file mode 100644 index 0000000000..bfec1d1fb2 --- /dev/null +++ b/services/web/test/acceptance/src/ConvertSplitTestAssignedAtToDates.js @@ -0,0 +1,134 @@ +import { expect } from 'chai' +import { db } from '../../../app/src/infrastructure/mongodb.js' +import { exec } from 'node:child_process' + +describe('ConvertSplitTestAssignedAtToDates', function () { + beforeEach('insert data', async function () { + await db.users.insertMany([ + { + email: 'foo0@bar.com', + splitTests: { + 'split-test-1': [ + { + variantName: 'enabled', + versionNumber: 4, + phase: 'release', + assignedAt: '2025-03-18T13:19:46.627Z', + }, + { + variantName: 'default', + versionNumber: 5, + phase: 'release', + assignedAt: new Date('2025-04-30T08:52:13.783Z'), + }, + ], + 'split-test-2': [ + { + variantName: 'active', + versionNumber: 5, + phase: 'release', + assignedAt: new Date('2025-02-14T09:08:30.190Z'), + }, + { + variantName: 'active', + versionNumber: 7, + phase: 'release', + assignedAt: new Date('2025-03-11T11:05:13.640Z'), + }, + ], + }, + }, + { + email: 'foo1@bar.com', + splitTests: { + 'split-test-3': [ + { + variantName: 'default', + versionNumber: 1, + phase: 'release', + assignedAt: '2025-02-11T14:55:38.470Z', + }, + { + variantName: 'enabled', + versionNumber: 21, + phase: 'release', + assignedAt: '2025-03-18T13:19:46.826Z', + }, + ], + }, + }, + { + email: 'foo2@bar.com', + }, + ]) + }) + + beforeEach('run migration', function (done) { + exec( + 'east migrate -t saas --force 20210726083523_convert_split_tests_assigned_at_strings_to_dates', + done + ) + }) + + it('should update the dates', async function () { + expect( + await db.users.find({}, { projection: { _id: 0 } }).toArray() + ).to.deep.equal([ + { + email: 'foo0@bar.com', + splitTests: { + 'split-test-1': [ + { + variantName: 'enabled', + versionNumber: 4, + phase: 'release', + assignedAt: new Date('2025-03-18T13:19:46.627Z'), + }, + { + variantName: 'default', + versionNumber: 5, + phase: 'release', + assignedAt: new Date('2025-04-30T08:52:13.783Z'), + }, + ], + 'split-test-2': [ + { + variantName: 'active', + versionNumber: 5, + phase: 'release', + assignedAt: new Date('2025-02-14T09:08:30.190Z'), + }, + { + variantName: 'active', + versionNumber: 7, + phase: 'release', + assignedAt: new Date('2025-03-11T11:05:13.640Z'), + }, + ], + }, + }, + { + email: 'foo1@bar.com', + splitTests: { + 'split-test-3': [ + { + variantName: 'default', + versionNumber: 1, + phase: 'release', + assignedAt: new Date('2025-02-11T14:55:38.470Z'), + }, + { + variantName: 'enabled', + versionNumber: 21, + phase: 'release', + assignedAt: new Date('2025-03-18T13:19:46.826Z'), + }, + ], + }, + }, + { + email: 'foo2@bar.com', + }, + ]) + }) +}) diff --git a/services/web/test/acceptance/src/DeletionTests.mjs b/services/web/test/acceptance/src/DeletionTests.mjs index 121c2580d8..e589be9474 100644 --- a/services/web/test/acceptance/src/DeletionTests.mjs +++ b/services/web/test/acceptance/src/DeletionTests.mjs @@ -1,3 +1,5 @@ +import logger from '@overleaf/logger' +import sinon from 'sinon' import User from './helpers/User.mjs' import Subscription from './helpers/Subscription.mjs' import request from './helpers/request.js' @@ -18,6 +20,8 @@ let MockDocstoreApi, MockGitBridgeApi, MockHistoryBackupDeletionApi +let spy + before(function () { MockDocstoreApi = MockDocstoreApiClass.instance() MockFilestoreApi = MockFilestoreApiClass.instance() @@ -28,6 +32,7 @@ before(function () { describe('Deleting a user', function () { beforeEach(function (done) { + spy = sinon.spy(logger, 'info') async.auto( { user: cb => { @@ -64,6 +69,10 @@ describe('Deleting a user', function () { ) }) + afterEach(function () { + spy.restore() + }) + it('Should remove the user from active users', function (done) { this.user.get((error, user) => { expect(error).not.to.exist @@ -183,6 +192,7 @@ describe('Deleting a user', function () { describe('Deleting a project', function () { beforeEach(function (done) { + spy = sinon.spy(logger, 'info') this.user = new User() this.projectName = 'wombat' this.user.ensureUserExists(() => { @@ -195,6 +205,10 @@ describe('Deleting a project', function () { }) }) + afterEach(function () { + logger.info.restore() + }) + it('Should remove the project from active projects', function (done) { this.user.getProject(this.projectId, (error, project) => { expect(error).not.to.exist @@ -292,6 +306,28 @@ describe('Deleting a project', function () { }) }) + it('Should log a successful deletion', function (done) { + request.post( + `/internal/project/${this.projectId}/expire-deleted-project`, + { + auth: { + user: settings.apis.web.user, + pass: settings.apis.web.pass, + sendImmediately: true, + }, + }, + (error, res) => { + expect(error).not.to.exist + expect(res.statusCode).to.equal(200) + expect(spy).to.have.been.calledWithMatch( + { projectId: this.projectId, userId: this.user._id }, + 'expired deleted project successfully' + ) + done() + } + ) + }) + it('Should destroy the docs', function (done) { expect( MockDocstoreApi.docs[this.projectId.toString()][this.docId.toString()] diff --git a/services/web/test/acceptance/src/ProjectCRUDTests.mjs b/services/web/test/acceptance/src/ProjectCRUDTests.mjs index 2212dc8e8b..082499ba91 100644 --- a/services/web/test/acceptance/src/ProjectCRUDTests.mjs +++ b/services/web/test/acceptance/src/ProjectCRUDTests.mjs @@ -125,25 +125,6 @@ describe('Project CRUD', function () { expectObjectIdArrayEqual(trashedProject.archived, []) }) }) - - describe('with a legacy boolean state', function () { - it('should mark the project as not archived for the user', async function () { - await Project.updateOne( - { _id: this.projectId }, - { $set: { archived: true } } - ).exec() - - const { response } = await this.user.doRequest( - 'POST', - `/project/${this.projectId}/trash` - ) - - expect(response.statusCode).to.equal(200) - - const trashedProject = await Project.findById(this.projectId).exec() - expectObjectIdArrayEqual(trashedProject.archived, []) - }) - }) }) describe('when untrashing a project', function () { diff --git a/services/web/test/acceptance/src/ProjectInviteTests.mjs b/services/web/test/acceptance/src/ProjectInviteTests.mjs index df13d71d37..e61feff6c9 100644 --- a/services/web/test/acceptance/src/ProjectInviteTests.mjs +++ b/services/web/test/acceptance/src/ProjectInviteTests.mjs @@ -200,7 +200,7 @@ const expectInvitePage = (user, link, callback) => { tryFollowInviteLink(user, link, (err, response, body) => { expect(err).not.to.exist expect(response.statusCode).to.equal(200) - expect(body).to.match(/Project Invite - .*<\/title>/) + expect(body).to.match(/<title[^>]*>Project Invite - .*<\/title>/) callback() }) } @@ -210,7 +210,7 @@ const expectInvalidInvitePage = (user, link, callback) => { tryFollowInviteLink(user, link, (err, response, body) => { expect(err).not.to.exist expect(response.statusCode).to.equal(404) - expect(body).to.match(/<title>Invalid Invite - .*<\/title>/) + expect(body).to.match(/<title[^>]*>Invalid Invite - .*<\/title>/) callback() }) } @@ -237,7 +237,9 @@ const expectLoginPage = (user, callback) => { tryFollowLoginLink(user, '/login', (err, response, body) => { expect(err).not.to.exist expect(response.statusCode).to.equal(200) - expect(body).to.match(/<title>(Login|Log in to Overleaf) - .*<\/title>/) + expect(body).to.match( + /<title[^>]*>(Login|Log in to Overleaf) - .*<\/title>/ + ) callback() }) } diff --git a/services/web/test/acceptance/src/helpers/Subscription.mjs b/services/web/test/acceptance/src/helpers/Subscription.mjs index 420dda56d9..1ce0e87afd 100644 --- a/services/web/test/acceptance/src/helpers/Subscription.mjs +++ b/services/web/test/acceptance/src/helpers/Subscription.mjs @@ -85,7 +85,14 @@ class PromisifiedSubscription { } async enableManagedUsers() { - await Modules.promises.hooks.fire('enableManagedUsers', this._id) + await Modules.promises.hooks.fire('enableManagedUsers', this._id, { + initiatorId: this.admin_id, + ipAddress: '123.456.789.0', + }) + } + + async disableManagedUsers() { + await Modules.promises.hooks.fire('disableManagedUsers', this._id) } async enableFeatureSSO() { diff --git a/services/web/test/acceptance/src/helpers/User.mjs b/services/web/test/acceptance/src/helpers/User.mjs index 361466e259..4371ecbe49 100644 --- a/services/web/test/acceptance/src/helpers/User.mjs +++ b/services/web/test/acceptance/src/helpers/User.mjs @@ -10,6 +10,7 @@ import fs from 'node:fs' import Path from 'node:path' import { fileURLToPath } from 'node:url' import { Cookie } from 'tough-cookie' + const __dirname = fileURLToPath(new URL('.', import.meta.url)) const COOKIE_DOMAIN = settings.cookieDomain // The cookie domain has a leading '.' but the cookie jar stores it without. @@ -37,6 +38,7 @@ class User { jar: this.jar, }) this.signUpDate = options.signUpDate ?? new Date() + this.labsProgram = options.labsProgram || false } getSession(options, callback) { @@ -257,13 +259,19 @@ class User { getAuditLog(callback) { this.get((error, user) => { - if (error) return callback(error) - if (!user) return callback(new Error('User not found')) + if (error) { + return callback(error) + } + if (!user) { + return callback(new Error('User not found')) + } db.userAuditLogEntries .find({ userId: new ObjectId(this._id) }) .toArray((error, auditLog) => { - if (error) return callback(error) + if (error) { + return callback(error) + } callback(null, auditLog || []) }) }) @@ -271,7 +279,9 @@ class User { getAuditLogWithoutNoise(callback) { this.getAuditLog((error, auditLog) => { - if (error) return callback(error) + if (error) { + return callback(error) + } callback( null, auditLog.filter(entry => { @@ -413,7 +423,9 @@ class User { } ensureUserExists(callback) { - if (this._id) return callback() // already exists + if (this._id) { + return callback() + } // already exists const filter = { email: this.email } const options = { upsert: true, new: true, setDefaultsOnInsert: true } @@ -431,6 +443,7 @@ class User { hashedPassword, emails: this.emails, signUpDate: this.signUpDate, + labsProgram: this.labsProgram, }, }, options @@ -447,7 +460,9 @@ class User { // Update and persist feature upgrade. Downgrades will be flaky! upgradeFeatures(features, callback) { this.setFeatures(features, err => { - if (err) return callback(err) + if (err) { + return callback(err) + } // Persist the feature update, otherwise the next feature refresh will reset them. this.setFeaturesOverride( { @@ -1195,7 +1210,9 @@ class User { json: newSettings, }, (err, response, body) => { - if (err) return callback(err) + if (err) { + return callback(err) + } if (response.statusCode !== 200) { return callback( new Error( diff --git a/services/web/test/acceptance/src/helpers/UserHelper.mjs b/services/web/test/acceptance/src/helpers/UserHelper.mjs index cfeafed47c..951dccaf2b 100644 --- a/services/web/test/acceptance/src/helpers/UserHelper.mjs +++ b/services/web/test/acceptance/src/helpers/UserHelper.mjs @@ -7,7 +7,6 @@ import UserGetter from '../../../../app/src/Features/User/UserGetter.js' import UserUpdater from '../../../../app/src/Features/User/UserUpdater.js' import moment from 'moment' import fetch from 'node-fetch' -import { db } from '../../../../app/src/infrastructure/mongodb.js' import mongodb from 'mongodb-legacy' import { UserAuditLogEntry } from '../../../../app/src/models/UserAuditLogEntry.js' @@ -19,11 +18,20 @@ import { RateLimiter } from '../../../../app/src/infrastructure/RateLimiter.js' const { ObjectId } = mongodb const rateLimiters = { - resendConfirmation: new RateLimiter('resend-confirmation'), + sendConfirmation: new RateLimiter('send-confirmation'), } let globalUserNum = Settings.test.counterInit +const throwIfErrorResponse = async response => { + if (response.status < 200 || response.status >= 300) { + const body = await response.text() + throw new Error( + `request failed: status=${response.status} body=${JSON.stringify(body)}` + ) + } +} + class UserHelper { /** * Create UserHelper @@ -129,13 +137,7 @@ class UserHelper { // get csrf token from api and store const response = await this.fetch('/dev/csrf') const body = await response.text() - if (response.status !== 200) { - throw new Error( - `get csrf token failed: status=${response.status} body=${JSON.stringify( - body - )}` - ) - } + await throwIfErrorResponse(response) this._csrfToken = body } @@ -145,14 +147,7 @@ class UserHelper { async getSession() { const response = await this.fetch('/dev/session') const body = await response.text() - - if (response.status !== 200) { - throw new Error( - `get session failed: status=${response.status} body=${JSON.stringify( - body - )}` - ) - } + await throwIfErrorResponse(response) return JSON.parse(body) } @@ -160,24 +155,22 @@ class UserHelper { const response = await this.fetch( `/dev/split_test/get_assignment?splitTestName=${splitTestName}` ) + await throwIfErrorResponse(response) const body = await response.text() - - if (response.status !== 200) { - throw new Error( - `get split test assignment failed: status=${response.status} body=${JSON.stringify( - body - )}` - ) - } return JSON.parse(body) } - async getEmailConfirmationCode() { + /** + * + * @param {'pendingExistingEmail'|'pendingUserRegistration'}sessionKey + * @return {Promise<*>} + */ + async getEmailConfirmationCode(sessionKey) { const session = await this.getSession() - const code = session.pendingUserRegistration?.confirmCode + const code = session[sessionKey]?.confirmCode if (!code) { - throw new Error('No confirmation code found in session') + throw new Error(`No confirmation code found in session (${sessionKey})`) } return code } @@ -383,14 +376,8 @@ class UserHelper { body: JSON.stringify(userData), ...options, }) + await throwIfErrorResponse(response) const body = await response.json() - if (response.status !== 200) { - throw new Error( - `register failed: status=${response.status} body=${JSON.stringify( - body - )}` - ) - } if (body.message && body.message.type === 'error') { throw new Error(`register api error: ${body.message.text}`) } @@ -400,7 +387,9 @@ class UserHelper { ) } - const code = await userHelper.getEmailConfirmationCode() + const code = await userHelper.getEmailConfirmationCode( + 'pendingUserRegistration' + ) const confirmationResponse = await userHelper.fetch( '/registration/confirm-email', @@ -446,14 +435,7 @@ class UserHelper { method: 'POST', body: new URLSearchParams([['email', email]]), }) - const body = await response.text() - if (response.status !== 204) { - throw new Error( - `add email failed: status=${response.status} body=${JSON.stringify( - body - )}` - ) - } + await throwIfErrorResponse(response) } async addEmailAndConfirm(userId, email) { @@ -519,40 +501,21 @@ class UserHelper { async confirmEmail(userId, email) { // clear ratelimiting on resend confirmation endpoint - await rateLimiters.resendConfirmation.delete(userId) - // UserHelper.createUser does not create a confirmation token - let response = await this.fetch('/user/emails/resend_confirmation', { + await rateLimiters.sendConfirmation.delete(userId) + const requestConfirmationCode = await this.fetch( + '/user/emails/send-confirmation-code', + { + method: 'POST', + body: new URLSearchParams({ email }), + } + ) + await throwIfErrorResponse(requestConfirmationCode) + const code = await this.getEmailConfirmationCode('pendingExistingEmail') + const requestConfirmCode = await this.fetch('/user/emails/confirm-code', { method: 'POST', - body: new URLSearchParams([['email', email]]), + body: new URLSearchParams({ code }), }) - if (response.status !== 200) { - const body = await response.text() - throw new Error( - `resend confirmation failed: status=${ - response.status - } body=${JSON.stringify(body)}` - ) - } - const tokenData = await db.tokens - .find({ - use: 'email_confirmation', - 'data.user_id': userId.toString(), - 'data.email': email, - usedAt: { $exists: false }, - }) - .next() - response = await this.fetch('/user/emails/confirm', { - method: 'POST', - body: new URLSearchParams([['token', tokenData.token]]), - }) - if (response.status !== 200) { - const body = await response.text() - throw new Error( - `confirm email failed: status=${response.status} body=${JSON.stringify( - body - )}` - ) - } + await throwIfErrorResponse(requestConfirmCode) } } diff --git a/services/web/test/acceptance/src/helpers/expectErrorResponse.mjs b/services/web/test/acceptance/src/helpers/expectErrorResponse.mjs index e79bfd5b2e..2bc3010412 100644 --- a/services/web/test/acceptance/src/helpers/expectErrorResponse.mjs +++ b/services/web/test/acceptance/src/helpers/expectErrorResponse.mjs @@ -12,7 +12,7 @@ export default { restricted: { html(response, body) { expect(response.statusCode).to.equal(403) - expect(body).to.match(/<head><title>Restricted/) + expect(body).to.match(/<head><title translate="no">Restricted/) }, json(response, body) { expect(response.statusCode).to.equal(403) diff --git a/services/web/test/acceptance/src/helpers/groupSSO.mjs b/services/web/test/acceptance/src/helpers/groupSSO.mjs index 1953b3e787..4388279578 100644 --- a/services/web/test/acceptance/src/helpers/groupSSO.mjs +++ b/services/web/test/acceptance/src/helpers/groupSSO.mjs @@ -187,6 +187,29 @@ export async function linkGroupMember( return userHelper } +export async function checkUserHasSSOLinked(userId, groupId) { + const internalProviderId = getProviderId(groupId) + const user = await UserGetter.promises.getUser( + { _id: userId }, + { samlIdentifiers: 1, enrollment: 1 } + ) + + const { enrollment, samlIdentifiers } = user + const linkedToGroupSSO = samlIdentifiers.some( + identifier => identifier.providerId === internalProviderId + ) + if (!linkedToGroupSSO) { + throw new Error('user saml identifiers are not linked to subscription') + } + + const userIsEnrolledInSSO = enrollment.sso.some( + sso => sso.groupId.toString() === groupId.toString() + ) + if (!userIsEnrolledInSSO) { + throw new Error('user is not enrolled in subscription') + } +} + export async function setConfigAndEnableSSO( subscriptionHelper, adminEmailPassword, diff --git a/services/web/test/frontend/components/editor-left-menu/editor-left-menu.spec.tsx b/services/web/test/frontend/components/editor-left-menu/editor-left-menu.spec.tsx index 808f97bd4b..d65669f720 100644 --- a/services/web/test/frontend/components/editor-left-menu/editor-left-menu.spec.tsx +++ b/services/web/test/frontend/components/editor-left-menu/editor-left-menu.spec.tsx @@ -4,11 +4,16 @@ import { OverallThemeMeta, SpellCheckLanguage, } from '../../../../types/project-settings' -import { EditorProviders } from '../../helpers/editor-providers' +import { + EditorProviders, + makeProjectProvider, +} from '../../helpers/editor-providers' import { mockScope } from './scope' import { Folder } from '../../../../types/folder' import { docsInFolder } from '@/features/file-tree/util/docs-in-folder' import getMeta from '@/utils/meta' +import { mockProject } from '../../features/source-editor/helpers/mock-project' +import { UserId } from '../../../../types/user' describe('<EditorLeftMenu />', function () { beforeEach(function () { @@ -56,14 +61,15 @@ describe('<EditorLeftMenu />', function () { }) it('render full menu', function () { - const scope = mockScope({ - ui: { - leftMenuShown: true, - }, - }) + const scope = mockScope() + const project = mockProject() cy.mount( - <EditorProviders scope={scope}> + <EditorProviders + scope={scope} + layoutContext={{ leftMenuShown: true }} + providers={{ ProjectProvider: makeProjectProvider(project) }} + > <EditorLeftMenu /> </EditorProviders> ) @@ -110,14 +116,12 @@ describe('<EditorLeftMenu />', function () { describe('download menu', function () { it('have a correct source & pdf download url', function () { - const scope = mockScope({ - ui: { - leftMenuShown: true, - }, - }) - + const scope = mockScope() cy.mount( - <EditorProviders scope={scope}> + <EditorProviders + scope={scope} + layoutContext={{ leftMenuShown: true }} + > <EditorLeftMenu /> </EditorProviders> ) @@ -142,14 +146,13 @@ describe('<EditorLeftMenu />', function () { }, }) - const scope = mockScope({ - ui: { - leftMenuShown: true, - }, - }) + const scope = mockScope() cy.mount( - <EditorProviders scope={scope}> + <EditorProviders + scope={scope} + layoutContext={{ leftMenuShown: true }} + > <EditorLeftMenu /> </EditorProviders> ) @@ -184,14 +187,13 @@ describe('<EditorLeftMenu />', function () { }, }).as('wordCount') - const scope = mockScope({ - ui: { - leftMenuShown: true, - }, - }) + const scope = mockScope() cy.mount( - <EditorProviders scope={scope}> + <EditorProviders + scope={scope} + layoutContext={{ leftMenuShown: true }} + > <EditorLeftMenu /> </EditorProviders> ) @@ -217,15 +219,6 @@ describe('<EditorLeftMenu />', function () { }) const scope = mockScope({ - ui: { - leftMenuShown: true, - }, - project: { - members: [], - owner: { - _id: '123', - }, - }, user: { features: { dropbox: false, @@ -234,7 +227,18 @@ describe('<EditorLeftMenu />', function () { }) cy.mount( - <EditorProviders scope={scope}> + <EditorProviders + scope={scope} + layoutContext={{ leftMenuShown: true }} + projectOwner={{ + _id: '123' as UserId, + email: 'owner@example.com', + first_name: 'Test', + last_name: 'Owner', + privileges: 'owner', + signUpDate: new Date('2025-07-07').toISOString(), + }} + > <EditorLeftMenu /> </EditorProviders> ) @@ -244,22 +248,26 @@ describe('<EditorLeftMenu />', function () { }) it('shows git modal correctly', function () { - const scope = mockScope({ - ui: { - leftMenuShown: true, - }, - project: { - owner: { - _id: '123', - }, - features: { - gitBridge: true, - }, - }, - }) + const scope = mockScope() cy.mount( - <EditorProviders scope={scope}> + <EditorProviders + scope={scope} + layoutContext={{ leftMenuShown: true }} + projectOwner={{ + _id: '123' as UserId, + email: 'owner@example.com', + first_name: 'Test', + last_name: 'Owner', + privileges: 'owner', + signUpDate: new Date('2025-07-07').toISOString(), + }} + projectFeatures={ + { + gitBridge: true, + } as any + } + > <EditorLeftMenu /> </EditorProviders> ) @@ -270,22 +278,26 @@ describe('<EditorLeftMenu />', function () { }) it('shows git modal paywall correctly', function () { - const scope = mockScope({ - ui: { - leftMenuShown: true, - }, - project: { - owner: { - _id: '123', - }, - features: { - gitBridge: false, - }, - }, - }) + const scope = mockScope() cy.mount( - <EditorProviders scope={scope}> + <EditorProviders + scope={scope} + layoutContext={{ leftMenuShown: true }} + projectOwner={{ + _id: '123' as UserId, + email: 'owner@example.com', + first_name: 'Test', + last_name: 'Owner', + privileges: 'owner', + signUpDate: new Date('2025-07-07').toISOString(), + }} + projectFeatures={ + { + gitBridge: false, + } as any + } + > <EditorLeftMenu /> </EditorProviders> ) @@ -304,14 +316,13 @@ describe('<EditorLeftMenu />', function () { enabled: false, }).as('project-status') - const scope = mockScope({ - ui: { - leftMenuShown: true, - }, - }) + const scope = mockScope() cy.mount( - <EditorProviders scope={scope}> + <EditorProviders + scope={scope} + layoutContext={{ leftMenuShown: true }} + > <EditorLeftMenu /> </EditorProviders> ) @@ -335,14 +346,13 @@ describe('<EditorLeftMenu />', function () { describe('settings menu', function () { it('shows compiler menu correctly', function () { - const scope = mockScope({ - ui: { - leftMenuShown: true, - }, - }) + const scope = mockScope() cy.mount( - <EditorProviders scope={scope}> + <EditorProviders + scope={scope} + layoutContext={{ leftMenuShown: true }} + > <EditorLeftMenu /> </EditorProviders> ) @@ -369,14 +379,13 @@ describe('<EditorLeftMenu />', function () { }) it('shows texlive version menu correctly', function () { - const scope = mockScope({ - ui: { - leftMenuShown: true, - }, - }) + const scope = mockScope() cy.mount( - <EditorProviders scope={scope}> + <EditorProviders + scope={scope} + layoutContext={{ leftMenuShown: true }} + > <EditorLeftMenu /> </EditorProviders> ) @@ -410,14 +419,14 @@ describe('<EditorLeftMenu />', function () { folders: [], } - const scope = mockScope({ - ui: { - leftMenuShown: true, - }, - }) + const scope = mockScope() cy.mount( - <EditorProviders scope={scope} rootFolder={[rootFolder as any]}> + <EditorProviders + layoutContext={{ leftMenuShown: true }} + scope={scope} + rootFolder={[rootFolder as any]} + > <EditorLeftMenu /> </EditorProviders> ) @@ -451,14 +460,13 @@ describe('<EditorLeftMenu />', function () { window.metaAttributesCache.set('ol-languages', languages) - const scope = mockScope({ - ui: { - leftMenuShown: true, - }, - }) + const scope = mockScope() cy.mount( - <EditorProviders scope={scope}> + <EditorProviders + scope={scope} + layoutContext={{ leftMenuShown: true }} + > <EditorLeftMenu /> </EditorProviders> ) @@ -475,14 +483,13 @@ describe('<EditorLeftMenu />', function () { }) it('shows dictionary modal correctly', function () { - const scope = mockScope({ - ui: { - leftMenuShown: true, - }, - }) + const scope = mockScope() cy.mount( - <EditorProviders scope={scope}> + <EditorProviders + scope={scope} + layoutContext={{ leftMenuShown: true }} + > <EditorLeftMenu /> </EditorProviders> ) @@ -493,14 +500,13 @@ describe('<EditorLeftMenu />', function () { }) it('shows auto-complete menu correctly', function () { - const scope = mockScope({ - ui: { - leftMenuShown: true, - }, - }) + const scope = mockScope() cy.mount( - <EditorProviders scope={scope}> + <EditorProviders + scope={scope} + layoutContext={{ leftMenuShown: true }} + > <EditorLeftMenu /> </EditorProviders> ) @@ -517,14 +523,13 @@ describe('<EditorLeftMenu />', function () { }) it('shows auto-close brackets menu correctly', function () { - const scope = mockScope({ - ui: { - leftMenuShown: true, - }, - }) + const scope = mockScope() cy.mount( - <EditorProviders scope={scope}> + <EditorProviders + scope={scope} + layoutContext={{ leftMenuShown: true }} + > <EditorLeftMenu /> </EditorProviders> ) @@ -541,14 +546,13 @@ describe('<EditorLeftMenu />', function () { }) it('shows code check menu correctly', function () { - const scope = mockScope({ - ui: { - leftMenuShown: true, - }, - }) + const scope = mockScope() cy.mount( - <EditorProviders scope={scope}> + <EditorProviders + scope={scope} + layoutContext={{ leftMenuShown: true }} + > <EditorLeftMenu /> </EditorProviders> ) @@ -579,14 +583,13 @@ describe('<EditorLeftMenu />', function () { legacyEditorThemes ) - const scope = mockScope({ - ui: { - leftMenuShown: true, - }, - }) + const scope = mockScope() cy.mount( - <EditorProviders scope={scope}> + <EditorProviders + scope={scope} + layoutContext={{ leftMenuShown: true }} + > <EditorLeftMenu /> </EditorProviders> ) @@ -619,14 +622,13 @@ describe('<EditorLeftMenu />', function () { }) it('shows overall theme menu correctly', function () { - const scope = mockScope({ - ui: { - leftMenuShown: true, - }, - }) + const scope = mockScope() cy.mount( - <EditorProviders scope={scope}> + <EditorProviders + scope={scope} + layoutContext={{ leftMenuShown: true }} + > <EditorLeftMenu /> </EditorProviders> ) @@ -643,14 +645,13 @@ describe('<EditorLeftMenu />', function () { }) it('shows keybindings menu correctly', function () { - const scope = mockScope({ - ui: { - leftMenuShown: true, - }, - }) + const scope = mockScope() cy.mount( - <EditorProviders scope={scope}> + <EditorProviders + scope={scope} + layoutContext={{ leftMenuShown: true }} + > <EditorLeftMenu /> </EditorProviders> ) @@ -667,14 +668,13 @@ describe('<EditorLeftMenu />', function () { }) it('shows font size menu correctly', function () { - const scope = mockScope({ - ui: { - leftMenuShown: true, - }, - }) + const scope = mockScope() cy.mount( - <EditorProviders scope={scope}> + <EditorProviders + scope={scope} + layoutContext={{ leftMenuShown: true }} + > <EditorLeftMenu /> </EditorProviders> ) @@ -713,14 +713,13 @@ describe('<EditorLeftMenu />', function () { }) it('shows font family menu correctly', function () { - const scope = mockScope({ - ui: { - leftMenuShown: true, - }, - }) + const scope = mockScope() cy.mount( - <EditorProviders scope={scope}> + <EditorProviders + scope={scope} + layoutContext={{ leftMenuShown: true }} + > <EditorLeftMenu /> </EditorProviders> ) @@ -741,14 +740,13 @@ describe('<EditorLeftMenu />', function () { }) it('shows line height menu correctly', function () { - const scope = mockScope({ - ui: { - leftMenuShown: true, - }, - }) + const scope = mockScope() cy.mount( - <EditorProviders scope={scope}> + <EditorProviders + scope={scope} + layoutContext={{ leftMenuShown: true }} + > <EditorLeftMenu /> </EditorProviders> ) @@ -765,14 +763,13 @@ describe('<EditorLeftMenu />', function () { }) it('shows pdf viewer menu correctly', function () { - const scope = mockScope({ - ui: { - leftMenuShown: true, - }, - }) + const scope = mockScope() cy.mount( - <EditorProviders scope={scope}> + <EditorProviders + scope={scope} + layoutContext={{ leftMenuShown: true }} + > <EditorLeftMenu /> </EditorProviders> ) @@ -791,14 +788,13 @@ describe('<EditorLeftMenu />', function () { describe('help menu', function () { it('shows hotkeys modal correctly', function () { - const scope = mockScope({ - ui: { - leftMenuShown: true, - }, - }) + const scope = mockScope() cy.mount( - <EditorProviders scope={scope}> + <EditorProviders + scope={scope} + layoutContext={{ leftMenuShown: true }} + > <EditorLeftMenu /> </EditorProviders> ) @@ -808,14 +804,13 @@ describe('<EditorLeftMenu />', function () { }) it('shows correct url for documentation', function () { - const scope = mockScope({ - ui: { - leftMenuShown: true, - }, - }) + const scope = mockScope() cy.mount( - <EditorProviders scope={scope}> + <EditorProviders + scope={scope} + layoutContext={{ leftMenuShown: true }} + > <EditorLeftMenu /> </EditorProviders> ) @@ -828,14 +823,13 @@ describe('<EditorLeftMenu />', function () { }) it('shows correct contact us modal', function () { - const scope = mockScope({ - ui: { - leftMenuShown: true, - }, - }) + const scope = mockScope() cy.mount( - <EditorProviders scope={scope}> + <EditorProviders + scope={scope} + layoutContext={{ leftMenuShown: true }} + > <EditorLeftMenu /> </EditorProviders> ) @@ -848,16 +842,13 @@ describe('<EditorLeftMenu />', function () { describe('for anonymous users', function () { it('render minimal menu', function () { - const scope = mockScope({ - ui: { - leftMenuShown: true, - }, - }) + const scope = mockScope() + window.metaAttributesCache.set('ol-anonymous', true) Object.assign(getMeta('ol-ExposedSettings'), { ieeeBrandId: 123 }) cy.mount( - <EditorProviders scope={scope}> + <EditorProviders scope={scope} layoutContext={{ leftMenuShown: true }}> <EditorLeftMenu /> </EditorProviders> ) diff --git a/services/web/test/frontend/components/editor-left-menu/scope.tsx b/services/web/test/frontend/components/editor-left-menu/scope.tsx index ba1764c359..8281fe1f72 100644 --- a/services/web/test/frontend/components/editor-left-menu/scope.tsx +++ b/services/web/test/frontend/components/editor-left-menu/scope.tsx @@ -12,7 +12,6 @@ type Scope = { getSnapshot?: () => string } } - hasLintingError?: boolean ui?: { view?: 'editor' | 'history' | 'file' | 'pdf' pdfLayout?: 'flat' | 'sideBySide' | 'split' @@ -46,11 +45,5 @@ export const mockScope = (scope?: Scope) => ({ getSnapshot: () => 'some doc content', }, }, - hasLintingError: false, - ui: { - view: 'editor', - pdfLayout: 'sideBySide', - leftMenuShown: false, - }, ...scope, }) diff --git a/services/web/test/frontend/components/pdf-preview/pdf-logs-entries.spec.tsx b/services/web/test/frontend/components/pdf-preview/pdf-logs-entries.spec.tsx index 60326d8d3f..a3067b566e 100644 --- a/services/web/test/frontend/components/pdf-preview/pdf-logs-entries.spec.tsx +++ b/services/web/test/frontend/components/pdf-preview/pdf-logs-entries.spec.tsx @@ -11,6 +11,7 @@ import { import { EditorView } from '@codemirror/view' import { OpenDocuments } from '@/features/ide-react/editor/open-documents' import { LogEntry } from '@/features/pdf-preview/util/types' +import { EditorViewContext } from '@/features/ide-react/context/editor-view-context' describe('<PdfLogsEntries/>', function () { const fakeFindEntityResult: FindResult = { @@ -48,6 +49,19 @@ describe('<PdfLogsEntries/>', function () { ) } + const EditorViewProvider: FC<React.PropsWithChildren> = ({ children }) => { + const value = { + view: new EditorView({ doc: '\\documentclass{article}' }), + setView: cy.stub(), + } + + return ( + <EditorViewContext.Provider value={value}> + {children} + </EditorViewContext.Provider> + ) + } + const logEntries: LogEntry[] = [ { file: 'main.tex', @@ -62,10 +76,6 @@ describe('<PdfLogsEntries/>', function () { }, ] - const scope = { - 'editor.view': new EditorView({ doc: '\\documentclass{article}' }), - } - beforeEach(function () { cy.interceptCompile() cy.interceptEvents() @@ -73,7 +83,7 @@ describe('<PdfLogsEntries/>', function () { it('displays human readable hint', function () { cy.mount( - <EditorProviders scope={scope}> + <EditorProviders providers={{ EditorViewProvider }}> <PdfLogsEntries entries={logEntries} /> </EditorProviders> ) @@ -84,8 +94,11 @@ describe('<PdfLogsEntries/>', function () { it('opens doc on click', function () { cy.mount( <EditorProviders - scope={scope} - providers={{ EditorManagerProvider, FileTreePathProvider }} + providers={{ + EditorManagerProvider, + FileTreePathProvider, + EditorViewProvider, + }} > <PdfLogsEntries entries={logEntries} /> </EditorProviders> @@ -114,8 +127,11 @@ describe('<PdfLogsEntries/>', function () { cy.mount( <EditorProviders - scope={scope} - providers={{ EditorManagerProvider, FileTreePathProvider }} + providers={{ + EditorManagerProvider, + FileTreePathProvider, + EditorViewProvider, + }} > <PdfLogsEntries entries={logEntries} /> </EditorProviders> @@ -154,8 +170,11 @@ describe('<PdfLogsEntries/>', function () { cy.mount( <EditorProviders - scope={scope} - providers={{ EditorManagerProvider, FileTreePathProvider }} + providers={{ + EditorManagerProvider, + FileTreePathProvider, + EditorViewProvider, + }} > <PdfLogsEntries entries={logEntries} /> </EditorProviders> diff --git a/services/web/test/frontend/components/pdf-preview/pdf-preview.spec.tsx b/services/web/test/frontend/components/pdf-preview/pdf-preview.spec.tsx index ed0ab2ffc1..34a3893ab5 100644 --- a/services/web/test/frontend/components/pdf-preview/pdf-preview.spec.tsx +++ b/services/web/test/frontend/components/pdf-preview/pdf-preview.spec.tsx @@ -7,7 +7,9 @@ import { IdeView, useLayoutContext, } from '../../../../frontend/js/shared/context/layout-context' -import { FC, useEffect } from 'react' +import { FC, PropsWithChildren, useEffect } from 'react' +import { useLocalCompileContext } from '@/shared/context/local-compile-context' +import { ProjectCompiler } from '../../../../types/project-settings' const storeAndFireEvent = (win: typeof window, key: string, value: unknown) => { localStorage.setItem(key, value) @@ -214,7 +216,7 @@ describe('<PdfPreview/>', function () { cached: false, setup: () => {}, props: { - compiler: 'lualatex', + compiler: 'lualatex' as ProjectCompiler, }, }, 'ignores the compile from cache when draft mode changed': { @@ -462,14 +464,20 @@ describe('<PdfPreview/>', function () { const scope = mockScope() // enable linting in the editor const userSettings = { syntaxValidation: true } - // mock a linting error - scope.hasLintingError = true + + const WithLintingErrors: FC<PropsWithChildren> = ({ children }) => { + const { setHasLintingError } = useLocalCompileContext() + useEffect(() => setHasLintingError(true), [setHasLintingError]) + return children + } cy.mount( <EditorProviders scope={scope} userSettings={userSettings}> - <div className="pdf-viewer"> - <PdfPreview /> - </div> + <WithLintingErrors> + <div className="pdf-viewer"> + <PdfPreview /> + </div> + </WithLintingErrors> </EditorProviders> ) diff --git a/services/web/test/frontend/components/pdf-preview/pdf-synctex-controls.spec.tsx b/services/web/test/frontend/components/pdf-preview/pdf-synctex-controls.spec.tsx index b2a64b89a4..3cba86d03d 100644 --- a/services/web/test/frontend/components/pdf-preview/pdf-synctex-controls.spec.tsx +++ b/services/web/test/frontend/components/pdf-preview/pdf-synctex-controls.spec.tsx @@ -3,7 +3,10 @@ import { cloneDeep } from 'lodash' import { useDetachCompileContext as useCompileContext } from '../../../../frontend/js/shared/context/detach-compile-context' import { useFileTreeData } from '../../../../frontend/js/shared/context/file-tree-data-context' import { useEffect } from 'react' -import { EditorProviders } from '../../helpers/editor-providers' +import { + EditorProviders, + makeEditorOpenDocProvider, +} from '../../helpers/editor-providers' import { mockScope } from './scope' import { detachChannel, testDetachChannel } from '../../helpers/detach-channel' import { FindResult } from '@/features/file-tree/util/path' @@ -73,6 +76,23 @@ const WithSelectedEntities = ({ return null } +function mockProviders() { + return { + EditorOpenDocProvider: makeEditorOpenDocProvider({ + openDocName: 'main.tex', + currentDocumentId: null, + currentDocument: { + doc_id: 'test-doc', + getSnapshot: () => 'some doc content', + hasBufferedOps: () => false, + on: () => {}, + off: () => {}, + leaveAndCleanUpPromise: () => Promise.resolve(), + } as any, + }), + } +} + describe('<PdfSynctexControls/>', function () { beforeEach(function () { window.metaAttributesCache.set('ol-project_id', 'test-project') @@ -84,9 +104,10 @@ describe('<PdfSynctexControls/>', function () { cy.interceptCompile() const scope = mockScope() + const providers = mockProviders() cy.mount( - <EditorProviders scope={scope}> + <EditorProviders scope={scope} providers={providers}> <WithPosition mockPosition={mockPosition} /> <WithSelectedEntities mockSelectedEntities={mockSelectedEntities} /> <PdfSynctexControls /> @@ -145,9 +166,10 @@ describe('<PdfSynctexControls/>', function () { cy.interceptCompile() const scope = mockScope() + const providers = mockProviders() cy.mount( - <EditorProviders scope={scope}> + <EditorProviders scope={scope} providers={providers}> <WithPosition mockPosition={mockPosition} /> <WithSelectedEntities mockSelectedEntities={ @@ -169,9 +191,10 @@ describe('<PdfSynctexControls/>', function () { cy.interceptCompile() const scope = mockScope() + const providers = mockProviders() cy.mount( - <EditorProviders scope={scope}> + <EditorProviders scope={scope} providers={providers}> <WithPosition mockPosition={mockPosition} /> <WithSelectedEntities mockSelectedEntities={[{ type: 'fileRef' }] as FindResult[]} @@ -196,9 +219,10 @@ describe('<PdfSynctexControls/>', function () { cy.interceptCompile() const scope = mockScope() + const providers = mockProviders() cy.mount( - <EditorProviders scope={scope}> + <EditorProviders scope={scope} providers={providers}> <WithPosition mockPosition={mockPosition} /> <WithSelectedEntities mockSelectedEntities={mockSelectedEntities} /> <PdfSynctexControls /> @@ -218,9 +242,10 @@ describe('<PdfSynctexControls/>', function () { cy.interceptCompile() const scope = mockScope() + const providers = mockProviders() cy.mount( - <EditorProviders scope={scope}> + <EditorProviders scope={scope} providers={providers}> <WithPosition mockPosition={mockPosition} /> <WithSelectedEntities mockSelectedEntities={mockSelectedEntities} /> <PdfSynctexControls /> @@ -279,9 +304,10 @@ describe('<PdfSynctexControls/>', function () { cy.interceptCompile() const scope = mockScope() + const providers = mockProviders() cy.mount( - <EditorProviders scope={scope}> + <EditorProviders scope={scope} providers={providers}> <WithPosition mockPosition={mockPosition} /> <WithSelectedEntities mockSelectedEntities={mockSelectedEntities} /> <PdfSynctexControls /> @@ -317,9 +343,10 @@ describe('<PdfSynctexControls/>', function () { cy.interceptCompile() const scope = mockScope() + const providers = mockProviders() cy.mount( - <EditorProviders scope={scope}> + <EditorProviders scope={scope} providers={providers}> <WithPosition mockPosition={mockPosition} /> <PdfSynctexControls /> </EditorProviders> @@ -338,9 +365,10 @@ describe('<PdfSynctexControls/>', function () { cy.interceptCompile() const scope = mockScope() + const providers = mockProviders() cy.mount( - <EditorProviders scope={scope}> + <EditorProviders scope={scope} providers={providers}> <PdfSynctexControls /> </EditorProviders> ) @@ -385,9 +413,10 @@ describe('<PdfSynctexControls/>', function () { cy.interceptCompile() const scope = mockScope() + const providers = mockProviders() cy.mount( - <EditorProviders scope={scope}> + <EditorProviders scope={scope} providers={providers}> <WithPosition mockPosition={mockPosition} /> <PdfSynctexControls /> </EditorProviders> diff --git a/services/web/test/frontend/components/pdf-preview/scope.tsx b/services/web/test/frontend/components/pdf-preview/scope.tsx index bb4e4d9d1d..08dbebbd3c 100644 --- a/services/web/test/frontend/components/pdf-preview/scope.tsx +++ b/services/web/test/frontend/components/pdf-preview/scope.tsx @@ -6,7 +6,6 @@ export const mockScope = () => ({ pdfViewer: 'pdfjs', }, editor: { - open_doc_name: 'main.tex', sharejs_doc: { doc_id: 'test-doc', getSnapshot: () => 'some doc content', @@ -16,9 +15,4 @@ export const mockScope = () => ({ doc: '\\documentclass{article}', }), }, - hasLintingError: false, - ui: { - view: 'editor', - pdfLayout: 'sideBySide', - }, }) diff --git a/services/web/test/frontend/features/clone-project-modal/components/clone-project-modal.test.jsx b/services/web/test/frontend/features/clone-project-modal/components/clone-project-modal.test.jsx index 6a837ed81f..0f523349e6 100644 --- a/services/web/test/frontend/features/clone-project-modal/components/clone-project-modal.test.jsx +++ b/services/web/test/frontend/features/clone-project-modal/components/clone-project-modal.test.jsx @@ -15,9 +15,9 @@ describe('<EditorCloneProjectModalWrapper />', function () { fetchMock.removeRoutes().clearHistory() }) - const project = { - _id: 'project-1', - name: 'Test Project', + const contextProps = { + projectId: 'project-1', + projectName: 'Test Project', } it('renders the translated modal title', async function () { @@ -30,7 +30,7 @@ describe('<EditorCloneProjectModalWrapper />', function () { openProject={openProject} show />, - { scope: { project } } + contextProps ) await screen.findByText('Copy project') @@ -55,7 +55,7 @@ describe('<EditorCloneProjectModalWrapper />', function () { openProject={openProject} show />, - { scope: { project } } + contextProps ) const cancelButton = await screen.findByRole('button', { name: 'Cancel' }) @@ -123,7 +123,7 @@ describe('<EditorCloneProjectModalWrapper />', function () { openProject={openProject} show />, - { scope: { project } } + contextProps ) const button = await screen.findByRole('button', { name: 'Copy' }) @@ -160,7 +160,7 @@ describe('<EditorCloneProjectModalWrapper />', function () { openProject={openProject} show />, - { scope: { project } } + contextProps ) const button = await screen.findByRole('button', { name: 'Copy' }) diff --git a/services/web/test/frontend/features/editor-navigation-toolbar/components/chat-toggle-button.test.jsx b/services/web/test/frontend/features/editor-navigation-toolbar/components/chat-toggle-button.test.tsx similarity index 100% rename from services/web/test/frontend/features/editor-navigation-toolbar/components/chat-toggle-button.test.jsx rename to services/web/test/frontend/features/editor-navigation-toolbar/components/chat-toggle-button.test.tsx diff --git a/services/web/test/frontend/features/editor-navigation-toolbar/components/layout-dropdown-button.test.jsx b/services/web/test/frontend/features/editor-navigation-toolbar/components/layout-dropdown-button.test.tsx similarity index 88% rename from services/web/test/frontend/features/editor-navigation-toolbar/components/layout-dropdown-button.test.jsx rename to services/web/test/frontend/features/editor-navigation-toolbar/components/layout-dropdown-button.test.tsx index 55a5213a09..c3bd08ed89 100644 --- a/services/web/test/frontend/features/editor-navigation-toolbar/components/layout-dropdown-button.test.jsx +++ b/services/web/test/frontend/features/editor-navigation-toolbar/components/layout-dropdown-button.test.tsx @@ -5,13 +5,16 @@ import { screen, waitFor } from '@testing-library/react' import LayoutDropdownButton from '../../../../../frontend/js/features/editor-navigation-toolbar/components/layout-dropdown-button' import { renderWithEditorContext } from '../../../helpers/render-with-context' import * as eventTracking from '@/infrastructure/event-tracking' +import type { LayoutContextOwnStates } from '@/shared/context/layout-context' describe('<LayoutDropdownButton />', function () { - let openStub - let sendMBSpy - const defaultUi = { + let openStub: sinon.SinonStub + let sendMBSpy: sinon.SinonSpy + + const defaultLayout: Partial<LayoutContextOwnStates> = { pdfLayout: 'flat', view: 'pdf', + chatIsOpen: false, } beforeEach(function () { @@ -27,7 +30,9 @@ describe('<LayoutDropdownButton />', function () { it('should mark current layout option as selected', async function () { // Selected is aria-label, visually we show a checkmark - renderWithEditorContext(<LayoutDropdownButton />, { ui: defaultUi }) + renderWithEditorContext(<LayoutDropdownButton />, { + layoutContext: defaultLayout, + }) screen.getByRole('button', { name: 'Layout' }).click() @@ -69,7 +74,7 @@ describe('<LayoutDropdownButton />', function () { it('should not select any option in history view', async function () { // Selected is aria-label, visually we show a checkmark renderWithEditorContext(<LayoutDropdownButton />, { - ui: { ...defaultUi, view: 'history' }, + layoutContext: { ...defaultLayout, view: 'history' }, }) screen.getByRole('button', { name: 'Layout' }).click() @@ -112,9 +117,10 @@ describe('<LayoutDropdownButton />', function () { it('should treat file and editor views the same way', async function () { // Selected is aria-label, visually we show a checkmark renderWithEditorContext(<LayoutDropdownButton />, { - ui: { + layoutContext: { pdfLayout: 'flat', view: 'file', + chatIsOpen: false, }, }) @@ -156,12 +162,13 @@ describe('<LayoutDropdownButton />', function () { }) describe('on detach', async function () { - let originalBroadcastChannel + const originalBroadcastChannel = window.BroadcastChannel beforeEach(async function () { - window.BroadcastChannel = originalBroadcastChannel || true // ensure that window.BroadcastChannel is truthy + // @ts-expect-error + window.BroadcastChannel = true // ensure that window.BroadcastChannel is truthy renderWithEditorContext(<LayoutDropdownButton />, { - ui: { ...defaultUi, view: 'editor' }, + layoutContext: { ...defaultLayout, view: 'editor' }, }) screen.getByRole('button', { name: 'Layout' }).click() @@ -192,7 +199,7 @@ describe('<LayoutDropdownButton />', function () { beforeEach(async function () { window.metaAttributesCache.set('ol-detachRole', 'detacher') renderWithEditorContext(<LayoutDropdownButton />, { - ui: { ...defaultUi, view: 'editor' }, + layoutContext: { ...defaultLayout, view: 'editor' }, }) screen.getByRole('button', { name: 'Layout' }).click() diff --git a/services/web/test/frontend/features/editor-navigation-toolbar/components/online-users-widget.test.jsx b/services/web/test/frontend/features/editor-navigation-toolbar/components/online-users-widget.test.tsx similarity index 89% rename from services/web/test/frontend/features/editor-navigation-toolbar/components/online-users-widget.test.jsx rename to services/web/test/frontend/features/editor-navigation-toolbar/components/online-users-widget.test.tsx index 96a8abe0aa..42c7a04502 100644 --- a/services/web/test/frontend/features/editor-navigation-toolbar/components/online-users-widget.test.jsx +++ b/services/web/test/frontend/features/editor-navigation-toolbar/components/online-users-widget.test.tsx @@ -8,12 +8,16 @@ describe('<OnlineUsersWidget />', function () { const defaultProps = { onlineUsers: [ { + id: 'test_user', user_id: 'test_user', name: 'test_user', + email: 'test_email', }, { + id: 'another_test_user', user_id: 'another_test_user', name: 'another_test_user', + email: 'another_test_email', }, ], goToUser: () => {}, @@ -44,8 +48,10 @@ describe('<OnlineUsersWidget />', function () { fireEvent.click(icon) expect(props.goToUser).to.be.calledWith({ - name: 'test_user', + id: 'test_user', user_id: 'test_user', + name: 'test_user', + email: 'test_email', }) }) }) @@ -55,12 +61,16 @@ describe('<OnlineUsersWidget />', function () { ...defaultProps, onlineUsers: defaultProps.onlineUsers.concat([ { + id: 'user_3', user_id: 'user_3', name: 'user_3', + email: 'user_3', }, { + id: 'user_4', user_id: 'user_4', name: 'user_4', + email: 'user_4', }, ]), } @@ -96,8 +106,10 @@ describe('<OnlineUsersWidget />', function () { fireEvent.click(icon) expect(testProps.goToUser).to.be.calledWith({ - name: 'user_3', + id: 'user_3', user_id: 'user_3', + name: 'user_3', + email: 'user_3', }) }) }) diff --git a/services/web/test/frontend/features/editor-navigation-toolbar/components/project-name-editable-label.test.jsx b/services/web/test/frontend/features/editor-navigation-toolbar/components/project-name-editable-label.test.tsx similarity index 100% rename from services/web/test/frontend/features/editor-navigation-toolbar/components/project-name-editable-label.test.jsx rename to services/web/test/frontend/features/editor-navigation-toolbar/components/project-name-editable-label.test.tsx diff --git a/services/web/test/frontend/features/editor-navigation-toolbar/components/toolbar-header.test.jsx b/services/web/test/frontend/features/editor-navigation-toolbar/components/toolbar-header.test.tsx similarity index 89% rename from services/web/test/frontend/features/editor-navigation-toolbar/components/toolbar-header.test.jsx rename to services/web/test/frontend/features/editor-navigation-toolbar/components/toolbar-header.test.tsx index be7894fc73..9b2217744e 100644 --- a/services/web/test/frontend/features/editor-navigation-toolbar/components/toolbar-header.test.jsx +++ b/services/web/test/frontend/features/editor-navigation-toolbar/components/toolbar-header.test.tsx @@ -1,11 +1,13 @@ import { expect } from 'chai' import { screen } from '@testing-library/react' -import ToolbarHeader from '../../../../../frontend/js/features/editor-navigation-toolbar/components/toolbar-header' +import ToolbarHeader, { + type ToolbarHeaderProps, +} from '../../../../../frontend/js/features/editor-navigation-toolbar/components/toolbar-header' import { renderWithEditorContext } from '../../../helpers/render-with-context' describe('<ToolbarHeader />', function () { - const defaultProps = { + const defaultProps: ToolbarHeaderProps = { onShowLeftMenuClick: () => {}, toggleChatOpen: () => {}, toggleReviewPanelOpen: () => {}, @@ -19,11 +21,12 @@ describe('<ToolbarHeader />', function () { hasPublishPermissions: true, chatVisible: true, trackChangesVisible: true, - handleChangeLayout: () => {}, - pdfLayout: 'sideBySide', - view: 'editor', - reattach: () => {}, - detach: () => {}, + cobranding: undefined, + isRestrictedTokenMember: false, + hasRenamePermissions: true, + historyIsOpen: false, + chatIsOpen: false, + reviewPanelOpen: false, } beforeEach(function () { @@ -40,6 +43,8 @@ describe('<ToolbarHeader />', function () { const props = { ...defaultProps, cobranding: { + brandId: 12, + brandVariationId: 12, brandVariationHomeUrl: 'http://cobranding', brandVariationName: 'variation', logoImgUrl: 'http://cobranding/logo', diff --git a/services/web/test/frontend/features/file-tree/components/file-tree-root.spec.tsx b/services/web/test/frontend/features/file-tree/components/file-tree-root.spec.tsx index 6e56b1b6bb..0739f5a3d5 100644 --- a/services/web/test/frontend/features/file-tree/components/file-tree-root.spec.tsx +++ b/services/web/test/frontend/features/file-tree/components/file-tree-root.spec.tsx @@ -1,6 +1,7 @@ import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root' import { EditorProviders } from '../../../helpers/editor-providers' import { SocketIOMock } from '@/ide/connection/SocketIoShim' +import type { Socket } from '@/features/ide-react/connection/types/socket' describe('<FileTreeRoot/>', function () { beforeEach(function () { @@ -245,9 +246,9 @@ describe('<FileTreeRoot/>', function () { }) describe('when deselecting files', function () { - let socket: SocketIOMock + let socket: SocketIOMock & Socket beforeEach(function () { - socket = new SocketIOMock() + socket = new SocketIOMock() as any const rootFolder = [ { _id: 'root-folder-id', diff --git a/services/web/test/frontend/features/file-tree/flows/create-folder.spec.tsx b/services/web/test/frontend/features/file-tree/flows/create-folder.spec.tsx index a7ccca400b..553757a1f1 100644 --- a/services/web/test/frontend/features/file-tree/flows/create-folder.spec.tsx +++ b/services/web/test/frontend/features/file-tree/flows/create-folder.spec.tsx @@ -1,11 +1,12 @@ import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root' import { EditorProviders } from '../../../helpers/editor-providers' import { SocketIOMock } from '@/ide/connection/SocketIoShim' +import type { Socket } from '@/features/ide-react/connection/types/socket' describe('FileTree Create Folder Flow', function () { - let socket: SocketIOMock + let socket: SocketIOMock & Socket beforeEach(function () { - socket = new SocketIOMock() + socket = new SocketIOMock() as any cy.window().then(win => { win.metaAttributesCache.set('ol-user', { id: 'user1' }) }) diff --git a/services/web/test/frontend/features/file-tree/flows/delete-entity.spec.tsx b/services/web/test/frontend/features/file-tree/flows/delete-entity.spec.tsx index af85aa2f46..22de945454 100644 --- a/services/web/test/frontend/features/file-tree/flows/delete-entity.spec.tsx +++ b/services/web/test/frontend/features/file-tree/flows/delete-entity.spec.tsx @@ -1,6 +1,7 @@ import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root' import { EditorProviders } from '../../../helpers/editor-providers' import { SocketIOMock } from '@/ide/connection/SocketIoShim' +import type { Socket } from '@/features/ide-react/connection/types/socket' describe('FileTree Delete Entity Flow', function () { beforeEach(function () { @@ -10,9 +11,9 @@ describe('FileTree Delete Entity Flow', function () { }) describe('single entity', function () { - let socket: SocketIOMock + let socket: SocketIOMock & Socket beforeEach(function () { - socket = new SocketIOMock() + socket = new SocketIOMock() as any const rootFolder = [ { _id: 'root-folder-id', @@ -136,9 +137,9 @@ describe('FileTree Delete Entity Flow', function () { }) describe('folders', function () { - let socket: SocketIOMock + let socket: SocketIOMock & Socket beforeEach(function () { - socket = new SocketIOMock() + socket = new SocketIOMock() as any const rootFolder = [ { _id: 'root-folder-id', @@ -207,9 +208,9 @@ describe('FileTree Delete Entity Flow', function () { }) describe('multiple entities', function () { - let socket: SocketIOMock + let socket: SocketIOMock & Socket beforeEach(function () { - socket = new SocketIOMock() + socket = new SocketIOMock() as any const rootFolder = [ { _id: 'root-folder-id', diff --git a/services/web/test/frontend/features/file-tree/flows/rename-entity.spec.tsx b/services/web/test/frontend/features/file-tree/flows/rename-entity.spec.tsx index afa849c265..dbff951dfc 100644 --- a/services/web/test/frontend/features/file-tree/flows/rename-entity.spec.tsx +++ b/services/web/test/frontend/features/file-tree/flows/rename-entity.spec.tsx @@ -1,6 +1,7 @@ import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root' import { EditorProviders } from '../../../helpers/editor-providers' import { SocketIOMock } from '@/ide/connection/SocketIoShim' +import type { Socket } from '@/features/ide-react/connection/types/socket' describe('FileTree Rename Entity Flow', function () { beforeEach(function () { @@ -9,9 +10,9 @@ describe('FileTree Rename Entity Flow', function () { }) }) - let socket: SocketIOMock + let socket: SocketIOMock & Socket beforeEach(function () { - socket = new SocketIOMock() + socket = new SocketIOMock() as any const rootFolder = [ { _id: 'root-folder-id', diff --git a/services/web/test/frontend/features/full-project-search/components/full-project-search.spec.tsx b/services/web/test/frontend/features/full-project-search/components/full-project-search.spec.tsx index 261d43017f..6e04a9cf3f 100644 --- a/services/web/test/frontend/features/full-project-search/components/full-project-search.spec.tsx +++ b/services/web/test/frontend/features/full-project-search/components/full-project-search.spec.tsx @@ -93,6 +93,9 @@ const createInitialValue = () => pdfPreviewOpen: false, projectSearchIsOpen: true, setProjectSearchIsOpen: cy.stub(), + openFile: null, + setOpenFile: cy.stub(), + restoreView: cy.stub(), }) satisfies LayoutContextValue const LayoutProvider: FC<React.PropsWithChildren> = ({ children }) => { diff --git a/services/web/test/frontend/features/group-management/components/add-seats.spec.tsx b/services/web/test/frontend/features/group-management/components/add-seats.spec.tsx index ead0c74a1c..f26357d842 100644 --- a/services/web/test/frontend/features/group-management/components/add-seats.spec.tsx +++ b/services/web/test/frontend/features/group-management/components/add-seats.spec.tsx @@ -14,9 +14,6 @@ describe('<AddSeats />', function () { win.metaAttributesCache.set('ol-totalLicenses', this.totalLicenses) win.metaAttributesCache.set('ol-isProfessional', false) win.metaAttributesCache.set('ol-isCollectionMethodManual', true) - win.metaAttributesCache.set('ol-splitTestVariants', { - 'flexible-group-licensing-for-manually-billed-subscriptions': 'enabled', - }) }) cy.mount( diff --git a/services/web/test/frontend/features/group-management/components/members-table/managed-user-status.spec.tsx b/services/web/test/frontend/features/group-management/components/members-table/managed-user-status.spec.tsx index 49cadfe0c9..5c09987695 100644 --- a/services/web/test/frontend/features/group-management/components/members-table/managed-user-status.spec.tsx +++ b/services/web/test/frontend/features/group-management/components/members-table/managed-user-status.spec.tsx @@ -61,26 +61,4 @@ describe('MemberStatus', function () { cy.get('.security-state-not-managed').contains('Managed') }) }) - - describe('with the group admin', function () { - const user: User = { - _id: 'some-user', - email: 'some.user@example.com', - first_name: 'Some', - last_name: 'User', - invite: false, - last_active_at: new Date(), - enrollment: undefined, - isEntityAdmin: true, - } - beforeEach(function () { - cy.mount(<ManagedUserStatus user={user} />) - }) - - it('should render no state indicator', function () { - cy.get('.security-state-group-admin') - .contains('Managed') - .should('not.exist') - }) - }) }) diff --git a/services/web/test/frontend/features/group-management/components/members-table/members-list.spec.tsx b/services/web/test/frontend/features/group-management/components/members-table/members-list.spec.tsx index 0861ab6c34..32958b34d0 100644 --- a/services/web/test/frontend/features/group-management/components/members-table/members-list.spec.tsx +++ b/services/web/test/frontend/features/group-management/components/members-table/members-list.spec.tsx @@ -121,6 +121,16 @@ describe('MembersList', function () { users[1].last_name ) }) + it('should render the pagination navigation', function () { + cy.window().then(win => { + win.metaAttributesCache.set( + 'ol-users', + Array.from({ length: 50 }).flatMap(() => users.flat()) + ) + }) + mountManagedUsersList() + cy.findByRole('navigation', { name: /pagination navigation/i }) + }) }) describe('empty user list', function () { diff --git a/services/web/test/frontend/features/history/components/change-list.spec.tsx b/services/web/test/frontend/features/history/components/change-list.spec.tsx index 763845db54..03c5a90592 100644 --- a/services/web/test/frontend/features/history/components/change-list.spec.tsx +++ b/services/web/test/frontend/features/history/components/change-list.spec.tsx @@ -14,11 +14,10 @@ import { withTestContainerErrorBoundary } from '../../../helpers/error-boundary' const TestContainerWithoutErrorBoundary: FC<{ component: React.ReactNode - scope: Record<string, unknown> props: Record<string, unknown> -}> = ({ component, scope, props }) => { +}> = ({ component, props }) => { return ( - <EditorProviders scope={scope} {...props}> + <EditorProviders {...props}> <HistoryProvider> <div style={{ display: 'flex', justifyContent: 'center' }}> <div className="history-react">{component}</div> @@ -34,17 +33,12 @@ const TestContainer = withTestContainerErrorBoundary( const mountWithEditorProviders = ( component: React.ReactNode, - scope: Record<string, unknown> = {}, props: Record<string, unknown> = {} ) => { - cy.mount(<TestContainer component={component} scope={scope} props={props} />) + cy.mount(<TestContainer component={component} props={props} />) } describe('change list (Bootstrap 5)', function () { - const scope = { - ui: { view: 'history', pdfLayout: 'sideBySide', chatOpen: true }, - } - const waitForData = () => { cy.wait('@updates') cy.wait('@labels') @@ -101,7 +95,8 @@ describe('change list (Bootstrap 5)', function () { describe('tags', function () { it('renders tags', function () { - mountWithEditorProviders(<ChangeList />, scope, { + mountWithEditorProviders(<ChangeList />, { + layoutContext: { view: 'history' }, user: { id: USER_ID, email: USER_EMAIL, @@ -173,13 +168,18 @@ describe('change list (Bootstrap 5)', function () { }) it('deletes tag', function () { - mountWithEditorProviders(<ChangeList />, scope, { - user: { - id: USER_ID, - email: USER_EMAIL, - isAdmin: true, - }, - }) + mountWithEditorProviders( + <ChangeList />, + + { + layoutContext: { view: 'history' }, + user: { + id: USER_ID, + email: USER_EMAIL, + isAdmin: true, + }, + } + ) waitForData() cy.findByLabelText(/all history/i).click({ force: true }) @@ -235,7 +235,8 @@ describe('change list (Bootstrap 5)', function () { }) it('verifies that selecting the same list item will not trigger a new diff', function () { - mountWithEditorProviders(<ChangeList />, scope, { + mountWithEditorProviders(<ChangeList />, { + layoutContext: { view: 'history' }, user: { id: USER_ID, email: USER_EMAIL, @@ -257,13 +258,18 @@ describe('change list (Bootstrap 5)', function () { describe('all history', function () { beforeEach(function () { - mountWithEditorProviders(<ChangeList />, scope, { - user: { - id: USER_ID, - email: USER_EMAIL, - isAdmin: true, - }, - }) + mountWithEditorProviders( + <ChangeList />, + + { + layoutContext: { view: 'history' }, + user: { + id: USER_ID, + email: USER_EMAIL, + isAdmin: true, + }, + } + ) waitForData() }) @@ -318,13 +324,18 @@ describe('change list (Bootstrap 5)', function () { describe('labels only', function () { beforeEach(function () { - mountWithEditorProviders(<ChangeList />, scope, { - user: { - id: USER_ID, - email: USER_EMAIL, - isAdmin: true, - }, - }) + mountWithEditorProviders( + <ChangeList />, + + { + layoutContext: { view: 'history' }, + user: { + id: USER_ID, + email: USER_EMAIL, + isAdmin: true, + }, + } + ) waitForData() cy.findByLabelText(/labels/i).click({ force: true }) }) @@ -399,13 +410,18 @@ describe('change list (Bootstrap 5)', function () { describe('compare mode', function () { beforeEach(function () { - mountWithEditorProviders(<ChangeList />, scope, { - user: { - id: USER_ID, - email: USER_EMAIL, - isAdmin: true, - }, - }) + mountWithEditorProviders( + <ChangeList />, + + { + layoutContext: { view: 'history' }, + user: { + id: USER_ID, + email: USER_EMAIL, + isAdmin: true, + }, + } + ) waitForData() }) @@ -435,13 +451,18 @@ describe('change list (Bootstrap 5)', function () { describe('dropdown', function () { beforeEach(function () { - mountWithEditorProviders(<ChangeList />, scope, { - user: { - id: USER_ID, - email: USER_EMAIL, - isAdmin: true, - }, - }) + mountWithEditorProviders( + <ChangeList />, + + { + layoutContext: { view: 'history' }, + user: { + id: USER_ID, + email: USER_EMAIL, + isAdmin: true, + }, + } + ) waitForData() }) @@ -610,21 +631,18 @@ describe('change list (Bootstrap 5)', function () { }) it('shows non-owner paywall', function () { - const scope = { - ui: { - view: 'history', - pdfLayout: 'sideBySide', - chatOpen: true, - }, - } + mountWithEditorProviders( + <ChangeList />, - mountWithEditorProviders(<ChangeList />, scope, { - user: { - id: USER_ID, - email: USER_EMAIL, - isAdmin: false, - }, - }) + { + layoutContext: { view: 'history' }, + user: { + id: USER_ID, + email: USER_EMAIL, + isAdmin: false, + }, + } + ) waitForData() @@ -634,15 +652,8 @@ describe('change list (Bootstrap 5)', function () { }) it('shows owner paywall', function () { - const scope = { - ui: { - view: 'history', - pdfLayout: 'sideBySide', - chatOpen: true, - }, - } - - mountWithEditorProviders(<ChangeList />, scope, { + mountWithEditorProviders(<ChangeList />, { + layoutContext: { view: 'history' }, user: { id: USER_ID, email: USER_EMAIL, @@ -662,15 +673,8 @@ describe('change list (Bootstrap 5)', function () { }) it('shows all labels in free tier', function () { - const scope = { - ui: { - view: 'history', - pdfLayout: 'sideBySide', - chatOpen: true, - }, - } - - mountWithEditorProviders(<ChangeList />, scope, { + mountWithEditorProviders(<ChangeList />, { + layoutContext: { view: 'history' }, user: { id: USER_ID, email: USER_EMAIL, diff --git a/services/web/test/frontend/features/history/components/toolbar.spec.tsx b/services/web/test/frontend/features/history/components/toolbar.spec.tsx index f60beff7ce..bc2b518eae 100644 --- a/services/web/test/frontend/features/history/components/toolbar.spec.tsx +++ b/services/web/test/frontend/features/history/components/toolbar.spec.tsx @@ -5,14 +5,15 @@ import { Diff } from '../../../../../frontend/js/features/history/services/types import { EditorProviders } from '../../../helpers/editor-providers' import { FC } from 'react' import { withTestContainerErrorBoundary } from '../../../helpers/error-boundary' +import { LayoutContextValue } from '@/shared/context/layout-context' const TestContainerWithoutErrorBoundary: FC<{ - scope: Record<string, unknown> + layoutContext: LayoutContextValue diff: Diff selection: HistoryContextValue['selection'] -}> = ({ scope, diff, selection }) => { +}> = ({ diff, selection, layoutContext }) => { return ( - <EditorProviders scope={scope}> + <EditorProviders layoutContext={layoutContext}> <HistoryProvider> <div className="history-react"> <Toolbar diff={diff} selection={selection} /> @@ -27,10 +28,6 @@ const TestContainer = withTestContainerErrorBoundary( ) describe('history toolbar', function () { - const editorProvidersScope = { - ui: { view: 'history', pdfLayout: 'sideBySide', chatOpen: true }, - } - const diff: Diff = { binary: false, docDiff: { @@ -81,7 +78,7 @@ describe('history toolbar', function () { cy.mount( <TestContainer - scope={editorProvidersScope} + layoutContext={{ view: 'history' }} diff={diff} selection={selection} /> @@ -129,7 +126,7 @@ describe('history toolbar', function () { cy.mount( <TestContainer - scope={editorProvidersScope} + layoutContext={{ view: 'history' }} diff={diff} selection={selection} /> diff --git a/services/web/test/frontend/features/hotkeys-modal/components/hotkeys-modal-bottom-text.test.jsx b/services/web/test/frontend/features/hotkeys-modal/components/hotkeys-modal-bottom-text.test.jsx index c3baeefa66..399a99ead4 100644 --- a/services/web/test/frontend/features/hotkeys-modal/components/hotkeys-modal-bottom-text.test.jsx +++ b/services/web/test/frontend/features/hotkeys-modal/components/hotkeys-modal-bottom-text.test.jsx @@ -15,7 +15,7 @@ describe('<HotkeysModalBottomText />', function () { }) expect(link.getAttribute('href')).to.equal( - `/articles/overleaf-keyboard-shortcuts/qykqfvmxdnjf` + `https://www.overleaf.com/articles/overleaf-keyboard-shortcuts/qykqfvmxdnjf` ) }) }) diff --git a/services/web/test/frontend/features/ide-react/unit/use-status-favicon.test.ts b/services/web/test/frontend/features/ide-react/unit/use-status-favicon.test.ts new file mode 100644 index 0000000000..f3a9294eb1 --- /dev/null +++ b/services/web/test/frontend/features/ide-react/unit/use-status-favicon.test.ts @@ -0,0 +1,131 @@ +import { expect } from 'chai' +import sinon from 'sinon' +import { renderHook } from '@testing-library/react' +import * as CompileContext from '@/shared/context/detach-compile-context' + +import { useStatusFavicon } from '@/features/ide-react/hooks/use-status-favicon' + +type Compilation = { uncompiled: boolean; compiling: boolean; error: boolean } + +describe('useStatusFavicon', function () { + let mockUseDetachCompileContext: sinon.SinonStub + let clock: sinon.SinonFakeTimers + let originalHidden: PropertyDescriptor | undefined + + const setCompilation = (compileContext: Compilation) => { + mockUseDetachCompileContext.returns(compileContext) + } + const setHidden = (hidden: boolean) => { + Object.defineProperty(document, 'hidden', { + writable: true, + configurable: true, + value: hidden, + }) + document.dispatchEvent(new Event('visibilitychange')) + } + + const getFaviconElements = () => + document.querySelectorAll('link[data-compile-status="true"]') + + const getCurrentFaviconHref = () => { + const favicon = document.querySelector( + 'link[data-compile-status="true"]' + ) as HTMLLinkElement + return favicon?.href || null + } + + beforeEach(function () { + mockUseDetachCompileContext = sinon.stub( + CompileContext, + 'useDetachCompileContext' + ) + + // Mock timers for timeout testing + clock = sinon.useFakeTimers() + + // Store original document.hidden descriptor + originalHidden = Object.getOwnPropertyDescriptor( + Document.prototype, + 'hidden' + ) + + // Clean up any existing favicon elements + document + .querySelectorAll('link[data-compile-status="true"]') + .forEach(el => el.remove()) + }) + + afterEach(function () { + sinon.restore() + clock.restore() + + // Restore original document.hidden + if (originalHidden) { + Object.defineProperty(document, 'hidden', originalHidden) + } + + // Clean up favicon elements + document + .querySelectorAll('link[data-compile-status="true"]') + .forEach(el => el.remove()) + }) + + it('updates favicon to reflect status: UNCOMPILED', function () { + setCompilation({ uncompiled: true, compiling: false, error: false }) + renderHook(() => useStatusFavicon()) + expect(getCurrentFaviconHref()).to.include('/favicon.svg') + }) + + it('updates favicon to reflect status: COMPILING', function () { + setCompilation({ uncompiled: false, compiling: true, error: false }) + renderHook(() => useStatusFavicon()) + expect(getCurrentFaviconHref()).to.include('/favicon-compiling.svg') + }) + + it('updates favicon to reflect status: COMPILED', function () { + setCompilation({ uncompiled: false, compiling: false, error: false }) + renderHook(() => useStatusFavicon()) + expect(getCurrentFaviconHref()).to.include('/favicon-compiled.svg') + }) + + it('updates favicon to reflect status: ERROR', function () { + setCompilation({ uncompiled: false, compiling: false, error: true }) + renderHook(() => useStatusFavicon()) + expect(getCurrentFaviconHref()).to.include('/favicon-error.svg') + }) + + it('keeps the COMPILED favicon for 5 seconds when the window is active', function () { + setCompilation({ uncompiled: false, compiling: false, error: false }) + const { rerender } = renderHook(() => useStatusFavicon()) + setHidden(false) + rerender() + expect(getCurrentFaviconHref()).to.include('/favicon-compiled.svg') + clock.tick(4500) + expect(getCurrentFaviconHref()).to.include('/favicon-compiled.svg') + clock.tick(1000) + expect(getCurrentFaviconHref()).to.include('/favicon.svg') + }) + + it('keeps the COMPILED favicon forever when the window is hidden', function () { + setCompilation({ uncompiled: false, compiling: false, error: false }) + const { rerender } = renderHook(() => useStatusFavicon()) + setHidden(true) + rerender() + + expect(getCurrentFaviconHref()).to.include('/favicon-compiled.svg') + clock.tick(90000) + expect(getCurrentFaviconHref()).to.include('/favicon-compiled.svg') + }) + + it('should only have one favicon element at a time', function () { + setCompilation({ uncompiled: true, compiling: false, error: false }) + const { rerender } = renderHook(() => useStatusFavicon()) + expect(getFaviconElements()).to.have.length(1) + expect(getCurrentFaviconHref()).to.include('/favicon.svg') + + setCompilation({ uncompiled: false, compiling: true, error: false }) + rerender() + expect(getFaviconElements()).to.have.length(1) + expect(getCurrentFaviconHref()).to.include('/favicon-compiling.svg') + }) +}) diff --git a/services/web/test/frontend/features/layout/components/switch-to-editor-button.spec.tsx b/services/web/test/frontend/features/layout/components/switch-to-editor-button.spec.tsx index 3cb8ea2fee..9efd500dff 100644 --- a/services/web/test/frontend/features/layout/components/switch-to-editor-button.spec.tsx +++ b/services/web/test/frontend/features/layout/components/switch-to-editor-button.spec.tsx @@ -4,7 +4,9 @@ import SwitchToEditorButton from '@/features/pdf-preview/components/switch-to-ed describe('<SwitchToEditorButton />', function () { it('shows button in full screen pdf layout', function () { cy.mount( - <EditorProviders ui={{ view: 'pdf', pdfLayout: 'flat', chatOpen: false }}> + <EditorProviders + layoutContext={{ view: 'pdf', pdfLayout: 'flat', chatIsOpen: false }} + > <SwitchToEditorButton /> </EditorProviders> ) @@ -15,7 +17,11 @@ describe('<SwitchToEditorButton />', function () { it('does not show button in split screen layout', function () { cy.mount( <EditorProviders - ui={{ view: 'pdf', pdfLayout: 'sideBySide', chatOpen: false }} + layoutContext={{ + view: 'pdf', + pdfLayout: 'sideBySide', + chatIsOpen: false, + }} > <SwitchToEditorButton /> </EditorProviders> @@ -28,7 +34,13 @@ describe('<SwitchToEditorButton />', function () { window.metaAttributesCache.set('ol-detachRole', 'detacher') cy.mount( - <EditorProviders ui={{ view: 'pdf', pdfLayout: 'flat', chatOpen: false }}> + <EditorProviders + layoutContext={{ + view: 'pdf', + pdfLayout: 'flat', + chatIsOpen: false, + }} + > <SwitchToEditorButton /> </EditorProviders> ) diff --git a/services/web/test/frontend/features/layout/components/switch-to-pdf-button.spec.tsx b/services/web/test/frontend/features/layout/components/switch-to-pdf-button.spec.tsx index 43fc1cabfc..04e20250d8 100644 --- a/services/web/test/frontend/features/layout/components/switch-to-pdf-button.spec.tsx +++ b/services/web/test/frontend/features/layout/components/switch-to-pdf-button.spec.tsx @@ -5,7 +5,7 @@ describe('<SwitchToPDFButton />', function () { it('shows button in full screen editor layout', function () { cy.mount( <EditorProviders - ui={{ view: 'editor', pdfLayout: 'flat', chatOpen: false }} + layoutContext={{ view: 'editor', pdfLayout: 'flat', chatIsOpen: false }} > <SwitchToPDFButton /> </EditorProviders> @@ -17,7 +17,11 @@ describe('<SwitchToPDFButton />', function () { it('does not show button in split screen layout', function () { cy.mount( <EditorProviders - ui={{ view: 'editor', pdfLayout: 'sideBySide', chatOpen: false }} + layoutContext={{ + view: 'editor', + pdfLayout: 'sideBySide', + chatIsOpen: false, + }} > <SwitchToPDFButton /> </EditorProviders> @@ -31,7 +35,7 @@ describe('<SwitchToPDFButton />', function () { cy.mount( <EditorProviders - ui={{ view: 'editor', pdfLayout: 'flat', chatOpen: false }} + layoutContext={{ view: 'editor', pdfLayout: 'flat', chatIsOpen: false }} > <SwitchToPDFButton /> </EditorProviders> diff --git a/services/web/test/frontend/features/project-list/components/current-plan-widget.test.tsx b/services/web/test/frontend/features/project-list/components/current-plan-widget.test.tsx index 84836c204b..00a8593169 100644 --- a/services/web/test/frontend/features/project-list/components/current-plan-widget.test.tsx +++ b/services/web/test/frontend/features/project-list/components/current-plan-widget.test.tsx @@ -83,6 +83,33 @@ describe('<CurrentPlanWidget />', function () { }) }) + describe('free plan with Personal plan name', function () { + beforeEach(function () { + window.metaAttributesCache.set('ol-usersBestSubscription', { + type: 'individual', + plan: { + name: 'Free', + }, + subscription: { + planCode: 'personal', + name: 'Free', + }, + featuresPageURL: '/features', + }) + + render(<CurrentPlanWidget />) + }) + + it('shows text and tooltip on mouseover', async function () { + const link = screen.getByRole('link', { + name: /you’re on the free plan/i, + }) + fireEvent.mouseOver(link) + + await screen.findByRole('tooltip', { name: freePlanTooltipMessage }) + }) + }) + describe('paid plan', function () { describe('trial', function () { const subscription = { diff --git a/services/web/test/frontend/features/project-list/components/notifications.test.tsx b/services/web/test/frontend/features/project-list/components/notifications.test.tsx index 9a845283d7..8813566a2e 100644 --- a/services/web/test/frontend/features/project-list/components/notifications.test.tsx +++ b/services/web/test/frontend/features/project-list/components/notifications.test.tsx @@ -614,8 +614,13 @@ describe('<UserNotifications />', function () { unconfirmedUserData, signUpDate ) + const dateOptions: Intl.DateTimeFormatOptions = { + month: 'long', + day: 'numeric', + year: 'numeric', + } expect(emailDeletionDate).to.equal( - new Date('2025-09-03').toLocaleDateString() + new Date('2025-09-03').toLocaleDateString(undefined, dateOptions) ) }) diff --git a/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx b/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx index abc92eefd1..f6068f106b 100644 --- a/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx +++ b/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx @@ -875,7 +875,7 @@ describe('<ProjectListRoot />', function () { const modals = await screen.findAllByRole('dialog') const modal = modals[0] - expect(sendMBSpy).to.have.been.calledTwice + expect(sendMBSpy).to.have.been.calledThrice expect(sendMBSpy).to.have.been.calledWith('loads_v2_dash') expect(sendMBSpy).to.have.been.calledWith( 'project-list-page-interaction', @@ -1014,7 +1014,7 @@ describe('<ProjectListRoot />', function () { ) ).to.be.true - expect(sendMBSpy).to.have.been.calledTwice + expect(sendMBSpy).to.have.been.calledThrice expect(sendMBSpy).to.have.been.calledWith('loads_v2_dash') expect(sendMBSpy).to.have.been.calledWith( 'project-list-page-interaction', @@ -1179,7 +1179,7 @@ describe('<ProjectListRoot />', function () { await fetchMock.callHistory.flush(true) - expect(sendMBSpy).to.have.been.calledTwice + expect(sendMBSpy).to.have.been.calledThrice expect(sendMBSpy).to.have.been.calledWith('loads_v2_dash') expect(sendMBSpy).to.have.been.calledWith( 'project-list-page-interaction', diff --git a/services/web/test/frontend/features/project-list/components/sidebar/add-affiliation.test.tsx b/services/web/test/frontend/features/project-list/components/sidebar/add-affiliation.test.tsx index c49cd38f2c..0d2d39890b 100644 --- a/services/web/test/frontend/features/project-list/components/sidebar/add-affiliation.test.tsx +++ b/services/web/test/frontend/features/project-list/components/sidebar/add-affiliation.test.tsx @@ -28,7 +28,9 @@ describe('Add affiliation widget', function () { renderWithProjectListContext(<AddAffiliation />) await fetchMock.callHistory.flush(true) - await waitFor(() => expect(fetchMock.callHistory.called('/api/project'))) + await waitFor( + () => expect(fetchMock.callHistory.called('/api/project')).to.be.true + ) await screen.findByText(/are you affiliated with an institution/i) const addAffiliationLink = screen.getByRole('link', { @@ -44,7 +46,9 @@ describe('Add affiliation widget', function () { renderWithProjectListContext(<AddAffiliation />) await fetchMock.callHistory.flush(true) - await waitFor(() => expect(fetchMock.callHistory.called('/api/project'))) + await waitFor( + () => expect(fetchMock.callHistory.called('/api/project')).to.be.true + ) validateNonExistence() }) @@ -58,7 +62,9 @@ describe('Add affiliation widget', function () { }) await fetchMock.callHistory.flush(true) - await waitFor(() => expect(fetchMock.callHistory.called('/api/project'))) + await waitFor( + () => expect(fetchMock.callHistory.called('/api/project')).to.be.true + ) validateNonExistence() }) @@ -70,7 +76,9 @@ describe('Add affiliation widget', function () { renderWithProjectListContext(<AddAffiliation />) await fetchMock.callHistory.flush(true) - await waitFor(() => expect(fetchMock.callHistory.called('/api/project'))) + await waitFor( + () => expect(fetchMock.callHistory.called('/api/project')).to.be.true + ) validateNonExistence() }) diff --git a/services/web/test/frontend/features/project-list/components/sidebar/tags-list.test.tsx b/services/web/test/frontend/features/project-list/components/sidebar/tags-list.test.tsx index b066d45244..2b721e373a 100644 --- a/services/web/test/frontend/features/project-list/components/sidebar/tags-list.test.tsx +++ b/services/web/test/frontend/features/project-list/components/sidebar/tags-list.test.tsx @@ -34,7 +34,9 @@ describe('<TagsList />', function () { renderWithProjectListContext(<TagsList />) await fetchMock.callHistory.flush(true) - await waitFor(() => expect(fetchMock.callHistory.called('/api/project'))) + await waitFor( + () => expect(fetchMock.callHistory.called('/api/project')).to.be.true + ) }) afterEach(function () { @@ -241,8 +243,10 @@ describe('<TagsList />', function () { fireEvent.click(saveButton) - await waitFor(() => - expect(fetchMock.callHistory.called(`/tag/abc123def456/rename`)) + await waitFor( + () => + expect(fetchMock.callHistory.called(`/tag/abc123def456/edit`)).to.be + .true ) await waitFor( @@ -286,8 +290,9 @@ describe('<TagsList />', function () { const deleteButton = within(modal).getByRole('button', { name: 'Delete' }) fireEvent.click(deleteButton) - await waitFor(() => - expect(fetchMock.callHistory.called(`/tag/bcd234efg567`)) + await waitFor( + () => + expect(fetchMock.callHistory.called(`/tag/abc123def456`)).to.be.true ) await waitFor( @@ -307,8 +312,9 @@ describe('<TagsList />', function () { const deleteButton = within(modal).getByRole('button', { name: 'Delete' }) fireEvent.click(deleteButton) - await waitFor(() => - expect(fetchMock.callHistory.called(`/tag/bcd234efg567`)) + await waitFor( + () => + expect(fetchMock.callHistory.called('/tag/abc123def456')).to.be.true ) await within(modal).findByText('Sorry, something went wrong') diff --git a/services/web/test/frontend/features/project-list/components/survey-widget.test.tsx b/services/web/test/frontend/features/project-list/components/survey-widget.test.tsx index 59f6bb9680..0c0d4f01a2 100644 --- a/services/web/test/frontend/features/project-list/components/survey-widget.test.tsx +++ b/services/web/test/frontend/features/project-list/components/survey-widget.test.tsx @@ -1,13 +1,14 @@ import { expect } from 'chai' import { fireEvent, render, screen } from '@testing-library/react' -import { SurveyWidgetDsNav } from '../../../../../frontend/js/features/project-list/components/survey-widget-ds-nav' +import { SurveyWidgetDsNav } from '@/features/project-list/components/survey-widget-ds-nav' import { SplitTestProvider } from '@/shared/context/split-test-context' describe('<SurveyWidgetDsNav />', function () { beforeEach(function () { this.name = 'my-survey' - this.preText = 'To help shape the future of Overleaf' - this.linkText = 'Click here!' + this.title = 'To help shape the future of Overleaf' + this.text = 'Click here!' + this.cta = 'Let’s go!' this.url = 'https://example.com/my-survey' localStorage.clear() @@ -17,8 +18,8 @@ describe('<SurveyWidgetDsNav />', function () { beforeEach(function () { window.metaAttributesCache.set('ol-survey', { name: this.name, - preText: this.preText, - linkText: this.linkText, + title: this.title, + text: this.text, url: this.url, }) @@ -33,8 +34,8 @@ describe('<SurveyWidgetDsNav />', function () { const dismissed = localStorage.getItem('dismissed-my-survey') expect(dismissed).to.equal(null) - screen.getByText(this.preText) - screen.getByText(this.linkText) + screen.getByText(this.title) + screen.getByText(this.text) const link = screen.getByRole('link', { name: 'Take survey', @@ -48,7 +49,7 @@ describe('<SurveyWidgetDsNav />', function () { }) fireEvent.click(dismissButton) - const text = screen.queryByText(this.preText) + const text = screen.queryByText(this.title) expect(text).to.be.null const link = screen.queryByRole('button') @@ -59,12 +60,43 @@ describe('<SurveyWidgetDsNav />', function () { }) }) + describe('survey widget is visible with custom CTA', function () { + beforeEach(function () { + window.metaAttributesCache.set('ol-survey', { + name: this.name, + title: this.title, + text: this.text, + cta: this.cta, + url: this.url, + }) + + render( + <SplitTestProvider> + <SurveyWidgetDsNav /> + </SplitTestProvider> + ) + }) + + it('shows text and link with custom CTA', function () { + const dismissed = localStorage.getItem('dismissed-my-survey') + expect(dismissed).to.equal(null) + + screen.getByText(this.title) + screen.getByText(this.text) + + const link = screen.getByRole('link', { + name: this.cta, + }) as HTMLAnchorElement + expect(link.href).to.equal(this.url) + }) + }) + describe('survey widget is not shown when already dismissed', function () { beforeEach(function () { window.metaAttributesCache.set('ol-survey', { name: this.name, - preText: this.preText, - linkText: this.linkText, + title: this.title, + text: this.text, url: this.url, }) localStorage.setItem('dismissed-my-survey', 'true') @@ -77,7 +109,7 @@ describe('<SurveyWidgetDsNav />', function () { }) it('nothing is displayed', function () { - const text = screen.queryByText(this.preText) + const text = screen.queryByText(this.title) expect(text).to.be.null const link = screen.queryByRole('button') @@ -95,7 +127,7 @@ describe('<SurveyWidgetDsNav />', function () { }) it('nothing is displayed', function () { - const text = screen.queryByText(this.preText) + const text = screen.queryByText(this.title) expect(text).to.be.null const link = screen.queryByRole('button') diff --git a/services/web/test/frontend/features/project-list/components/table/cells/inline-tags.test.tsx b/services/web/test/frontend/features/project-list/components/table/cells/inline-tags.test.tsx index 971b2aaa48..ddacc6da5b 100644 --- a/services/web/test/frontend/features/project-list/components/table/cells/inline-tags.test.tsx +++ b/services/web/test/frontend/features/project-list/components/table/cells/inline-tags.test.tsx @@ -58,15 +58,16 @@ describe('<InlineTags />', function () { name: 'Remove tag My Test Tag', }) fireEvent.click(removeButton) - await waitFor(() => - expect( - fetchMock.callHistory.called( - `/tag/789fff789fff/project/${copyableProject.id}`, - { - method: 'DELETE', - } - ) - ) + await waitFor( + () => + expect( + fetchMock.callHistory.called( + `/tag/789fff789fff/project/${copyableProject.id}`, + { + method: 'DELETE', + } + ) + ).to.be.true ) expect(screen.queryByText('My Test Tag')).to.not.exist screen.getByText('Tag 2') diff --git a/services/web/test/frontend/features/review-panel/review-panel.spec.tsx b/services/web/test/frontend/features/review-panel/review-panel.spec.tsx index d667787810..fcad15969b 100644 --- a/services/web/test/frontend/features/review-panel/review-panel.spec.tsx +++ b/services/web/test/frontend/features/review-panel/review-panel.spec.tsx @@ -1,12 +1,27 @@ import CodeMirrorEditor from '../../../../frontend/js/features/source-editor/components/codemirror-editor' import { EditorProviders, + makeProjectProvider, USER_EMAIL, USER_ID, } from '../../helpers/editor-providers' import { mockScope } from '../source-editor/helpers/mock-scope' import { TestContainer } from '../source-editor/helpers/test-container' import { docId } from '../source-editor/helpers/mock-doc' +import { mockProject } from '../source-editor/helpers/mock-project' + +const userData = { + avatar_text: 'User', + email: USER_EMAIL, + hue: 180, + id: USER_ID, + isSelf: true, + first_name: 'Test', + last_name: 'User', +} + +const resolvedThreadId = 'resolved-thread-id' +const unresolvedThreadId = 'unresolved-thread-id' describe('<ReviewPanel />', function () { beforeEach(function () { @@ -23,19 +38,6 @@ describe('<ReviewPanel />', function () { }, ]) - const userData = { - avatar_text: 'User', - email: USER_EMAIL, - hue: 180, - id: USER_ID, - isSelf: true, - first_name: 'Test', - last_name: 'User', - } - - const resolvedThreadId = 'resolved-thread-id' - const unresolvedThreadId = 'unresolved-thread-id' - cy.intercept('GET', '/project/*/threads', { // Resolved comment thread [resolvedThreadId]: { @@ -182,12 +184,21 @@ describe('<ReviewPanel />', function () { }, }, }) + const project = mockProject({ + projectOwner: { + _id: USER_ID, + }, + projectFeatures: { trackChanges: false, trackChangesVisible: true }, + }) cy.wrap(scope).as('scope') cy.mount( <TestContainer className="rp-size-expanded"> - <EditorProviders scope={scope}> + <EditorProviders + scope={scope} + providers={{ ProjectProvider: makeProjectProvider(project) }} + > <CodeMirrorEditor /> </EditorProviders> </TestContainer> @@ -622,11 +633,189 @@ describe('<ReviewPanel />', function () { }) }) +describe('<ReviewPanel /> in mini mode', function () { + function render({ comments = [], changes = [], threads = {} }: any) { + window.metaAttributesCache.set('ol-preventCompileOnLoad', true) + + cy.interceptEvents() + + cy.intercept('GET', '/project/*/changes/users', [ + { + id: USER_ID, + email: USER_EMAIL, + first_name: 'Test', + last_name: 'User', + }, + ]) + + const getChanges = cy.stub().as('getChanges').returns([]) + const removeChangeIds = cy.stub().as('removeChangeIds') + + const scope = mockScope(undefined, { + docOptions: { + rangesOptions: { + comments, + changes, + getChanges, + removeChangeIds, + }, + }, + projectFeatures: { trackChangesVisible: true }, + }) + + const project = mockProject({ + projectFeatures: { trackChangesVisible: true }, + }) + + cy.intercept('GET', '/project/*/ranges', [ + { + id: docId, + ranges: { + changes, + comments, + docId, + }, + }, + ]) + + cy.intercept('GET', '/project/*/threads', threads) + + cy.intercept('POST', `/project/*/doc/${docId}/metadata`, {}) + + cy.wrap(scope).as('scope') + + cy.mount( + <TestContainer> + <EditorProviders + scope={scope} + providers={{ ProjectProvider: makeProjectProvider(project) }} + > + <CodeMirrorEditor /> + </EditorProviders> + </TestContainer> + ) + // Wait for editor + cy.get('.cm-content').should('have.css', 'opacity', '1') + + // Toggle the review panel twice to ensure data is loaded + cy.findByText('contentLine 0').type('{command}jj', { + scrollBehavior: false, + }) + cy.findByText('contentLine 1').type('{ctrl}jj', { scrollBehavior: false }) + } + + it("doesn't render mini when no comments or changes are present in project", function () { + render({ + comments: [], + changes: [], + threads: {}, + }) + cy.get('.review-panel-mini').should('not.exist') + }) + + it("doesn't render mini when no comments or changes are present in document", function () { + render({ + comments: [], + changes: [], + threads: { + 'random-unrelated-thread': { + messages: [ + { + content: 'a comment', + id: 'random-unrelated-thread-1', + timestamp: new Date('2025-01-01T01:00:00.000Z'), + user: userData, + user_id: USER_ID, + }, + ], + }, + }, + }) + cy.get('.review-panel-mini').should('not.exist') + }) + + it("doesn't render mini when a resolved comment is present in document", function () { + render({ + comments: [ + { + id: resolvedThreadId, + op: { p: 161, c: 'Your introduction', t: resolvedThreadId }, + }, + ], + changes: [], + threads: { + [resolvedThreadId]: { + resolved: true, + resolved_at: new Date('2025-01-02T00:00:00.000Z').toISOString(), + resolved_by_user_id: USER_ID, + resolved_by_user: userData, + messages: [ + { + content: 'a comment', + id: `${resolvedThreadId}-1`, + timestamp: new Date('2025-01-01T01:00:00.000Z'), + user: userData, + user_id: USER_ID, + }, + ], + }, + }, + }) + cy.get('.review-panel-mini').should('not.exist') + }) + + it('renders mini when an unresolved comment is present in document', function () { + render({ + comments: [ + { + id: unresolvedThreadId, + op: { p: 161, c: 'Your introduction', t: unresolvedThreadId }, + }, + ], + changes: [], + threads: { + [unresolvedThreadId]: { + messages: [ + { + content: 'a comment', + id: `${unresolvedThreadId}-1`, + timestamp: new Date('2025-01-01T01:00:00.000Z'), + user: userData, + user_id: USER_ID, + }, + ], + }, + }, + }) + cy.get('.review-panel-mini').should('exist') + }) + + it('renders mini when a tracked change is present in document', function () { + render({ + comments: [], + changes: [ + { + metadata: { + user_id: USER_ID, + ts: new Date('2025-01-01T00:00:00.000Z'), + }, + id: 'inserted-op-id', + op: { p: 166, t: 'inserted-op-id', i: 'introduction' }, + }, + ], + threads: {}, + }) + cy.get('.review-panel-mini').should('exist') + }) +}) + describe('<ReviewPanel /> for free users', function () { function mountEditor(ownerId = USER_ID) { const scope = mockScope(undefined, { permissions: { write: true, trackedWrite: false, comment: true }, - projectFeatures: { trackChanges: false }, + }) + const project = mockProject({ + projectFeatures: { trackChanges: false, trackChangesVisible: true }, projectOwner: { _id: ownerId, }, @@ -636,7 +825,10 @@ describe('<ReviewPanel /> for free users', function () { cy.mount( <TestContainer className="rp-size-expanded"> - <EditorProviders scope={scope}> + <EditorProviders + scope={scope} + providers={{ ProjectProvider: makeProjectProvider(project) }} + > <CodeMirrorEditor /> </EditorProviders> </TestContainer> diff --git a/services/web/test/frontend/features/settings/components/account-info-section.test.tsx b/services/web/test/frontend/features/settings/components/account-info-section.test.tsx index e41cfe643f..fc6adebcda 100644 --- a/services/web/test/frontend/features/settings/components/account-info-section.test.tsx +++ b/services/web/test/frontend/features/settings/components/account-info-section.test.tsx @@ -129,7 +129,8 @@ describe('<AccountInfoSection />', function () { fetchMock.post('/user/settings', { status: 409, body: { - message: 'This email is already registered', + message: + 'This email address is already associated with a different Overleaf account.', }, }) renderSectionWithUserProvider() @@ -139,7 +140,9 @@ describe('<AccountInfoSection />', function () { name: /update/i, }) ) - await screen.findByText('This email is already registered') + await screen.findByText( + 'This email address is already associated with a different Overleaf account.' + ) }) it('hides email input', async function () { diff --git a/services/web/test/frontend/features/settings/components/linking/sso-widget.test.tsx b/services/web/test/frontend/features/settings/components/linking/sso-widget.test.tsx index 2f02b7709b..68c3f35c27 100644 --- a/services/web/test/frontend/features/settings/components/linking/sso-widget.test.tsx +++ b/services/web/test/frontend/features/settings/components/linking/sso-widget.test.tsx @@ -100,8 +100,8 @@ describe('<SSOLinkingWidget />', function () { }) ) fireEvent.click(confirmBtn) - await waitFor(() => - expect(screen.getByRole('button', { name: 'Unlinking' })) + await waitFor( + () => expect(screen.getByRole('button', { name: 'Unlinking' })).to.exist ) }) }) diff --git a/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.jsx b/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.jsx index b86207fb0f..548423e90f 100644 --- a/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.jsx +++ b/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.jsx @@ -1,17 +1,19 @@ import { expect } from 'chai' import sinon from 'sinon' -import { screen, fireEvent, render, waitFor } from '@testing-library/react' +import { screen, fireEvent, waitFor } from '@testing-library/react' import fetchMock from 'fetch-mock' import userEvent from '@testing-library/user-event' import ShareProjectModal from '../../../../../frontend/js/features/share-project-modal/components/share-project-modal' import { renderWithEditorContext } from '../../../helpers/render-with-context' import { - EditorProviders, + makeProjectProvider, + projectDefaults, USER_EMAIL, USER_ID, } from '../../../helpers/editor-providers' import { location } from '@/shared/components/location' +import { useProjectContext } from '@/shared/context/project-context' async function changePrivilegeLevel(screen, { current, next }) { const select = screen.getByDisplayValue(current) @@ -22,22 +24,25 @@ async function changePrivilegeLevel(screen, { current, next }) { fireEvent.click(option) } -describe('<ShareProjectModal/>', function () { - const project = { - _id: 'test-project', - name: 'Test Project', - features: { - collaborators: 10, - compileGroup: 'standard', - }, - owner: { - _id: USER_ID, - email: USER_EMAIL, - }, - members: [], - invites: [], - } +const shareModalProjectDefaults = Object.assign({}, projectDefaults, { + _id: 'test-project', + name: 'Test Project', + features: { + collaborators: 10, + compileGroup: 'standard', + }, + owner: { + _id: USER_ID, + email: USER_EMAIL, + }, +}) +function createContextProps(projectOverrides) { + const project = Object.assign({}, shareModalProjectDefaults, projectOverrides) + return { providers: { ProjectProvider: makeProjectProvider(project) } } +} + +describe('<ShareProjectModal/>', function () { const contacts = [ // user with edited name { @@ -100,9 +105,10 @@ describe('<ShareProjectModal/>', function () { }) it('renders the modal', async function () { - renderWithEditorContext(<ShareProjectModal {...modalProps} />, { - scope: { project }, - }) + renderWithEditorContext( + <ShareProjectModal {...modalProps} />, + createContextProps() + ) await screen.findByText('Share Project') }) @@ -112,7 +118,7 @@ describe('<ShareProjectModal/>', function () { renderWithEditorContext( <ShareProjectModal {...modalProps} handleHide={handleHide} />, - { scope: { project } } + createContextProps() ) const [headerCloseButton, footerCloseButton] = await screen.findAllByRole( @@ -127,9 +133,10 @@ describe('<ShareProjectModal/>', function () { }) it('handles access level "private"', async function () { - renderWithEditorContext(<ShareProjectModal {...modalProps} />, { - scope: { project: { ...project, publicAccesLevel: 'private' } }, - }) + renderWithEditorContext( + <ShareProjectModal {...modalProps} />, + createContextProps({ publicAccessLevel: 'private' }) + ) await screen.findByText('Link sharing is off') await screen.findByRole('button', { name: 'Turn on link sharing' }) @@ -148,10 +155,11 @@ describe('<ShareProjectModal/>', function () { readAndWriteHashPrefix: 'taEVki', readOnlyHashPrefix: 'j2xYbL', } - fetchMock.get(`/project/${project._id}/tokens`, tokens) - renderWithEditorContext(<ShareProjectModal {...modalProps} />, { - scope: { project: { ...project, publicAccesLevel: 'tokenBased' } }, - }) + fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, tokens) + renderWithEditorContext( + <ShareProjectModal {...modalProps} />, + createContextProps({ publicAccessLevel: 'tokenBased' }) + ) await screen.findByText('Link sharing is on') await screen.findByRole('button', { name: 'Turn off link sharing' }) @@ -170,9 +178,10 @@ describe('<ShareProjectModal/>', function () { }) it('handles legacy access level "readAndWrite"', async function () { - renderWithEditorContext(<ShareProjectModal {...modalProps} />, { - scope: { project: { ...project, publicAccesLevel: 'readAndWrite' } }, - }) + renderWithEditorContext( + <ShareProjectModal {...modalProps} />, + createContextProps({ publicAccessLevel: 'readAndWrite' }) + ) await screen.findByText( 'This project is public and can be edited by anyone with the URL.' @@ -181,9 +190,10 @@ describe('<ShareProjectModal/>', function () { }) it('handles legacy access level "readOnly"', async function () { - renderWithEditorContext(<ShareProjectModal {...modalProps} />, { - scope: { project: { ...project, publicAccesLevel: 'readOnly' } }, - }) + renderWithEditorContext( + <ShareProjectModal {...modalProps} />, + createContextProps({ publicAccessLevel: 'readOnly' }) + ) await screen.findByText( 'This project is public and can be viewed but not edited by anyone with the URL' @@ -192,7 +202,7 @@ describe('<ShareProjectModal/>', function () { }) it('displays actions for project-owners', async function () { - fetchMock.get(`/project/${project._id}/tokens`, {}) + fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {}) const invites = [ { @@ -203,18 +213,9 @@ describe('<ShareProjectModal/>', function () { ] // render as project owner: actions should be present - render( - <EditorProviders - scope={{ - project: { - ...project, - invites, - publicAccesLevel: 'tokenBased', - }, - }} - > - <ShareProjectModal {...modalProps} /> - </EditorProviders> + renderWithEditorContext( + <ShareProjectModal {...modalProps} />, + createContextProps({ publicAccessLevel: 'tokenBased', invites }) ) await screen.findByRole('button', { name: 'Turn off link sharing' }) @@ -230,23 +231,13 @@ describe('<ShareProjectModal/>', function () { }, ] - render( - <EditorProviders - scope={{ - project: { - ...project, - invites, - publicAccesLevel: 'tokenBased', - }, - }} - user={{ - id: 'non-project-owner', - email: 'non-project-owner@example.com', - }} - > - <ShareProjectModal {...modalProps} /> - </EditorProviders> - ) + renderWithEditorContext(<ShareProjectModal {...modalProps} />, { + ...createContextProps({ publicAccessLevel: 'tokenBased', invites }), + user: { + id: 'non-project-owner', + email: 'non-project-owner@example.com', + }, + }) await screen.findByText( 'To change access permissions, please ask the project owner' @@ -268,23 +259,13 @@ describe('<ShareProjectModal/>', function () { }, ] - render( - <EditorProviders - scope={{ - project: { - ...project, - invites, - publicAccesLevel: 'private', - }, - }} - user={{ - id: 'non-project-owner', - email: 'non-project-owner@example.com', - }} - > - <ShareProjectModal {...modalProps} /> - </EditorProviders> - ) + renderWithEditorContext(<ShareProjectModal {...modalProps} />, { + ...createContextProps({ publicAccessLevel: 'private', invites }), + user: { + id: 'non-project-owner', + email: 'non-project-owner@example.com', + }, + }) await screen.findByText( 'To add more collaborators or turn on link sharing, please ask the project owner' @@ -299,11 +280,11 @@ describe('<ShareProjectModal/>', function () { it('only shows read-only token link to restricted token members', async function () { window.metaAttributesCache.set('ol-isRestrictedTokenMember', true) - fetchMock.get(`/project/${project._id}/tokens`, {}) + fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {}) renderWithEditorContext(<ShareProjectModal {...modalProps} />, { + ...createContextProps({ publicAccessLevel: 'private' }), isRestrictedTokenMember: true, - scope: { project: { ...project, publicAccesLevel: 'tokenBased' } }, }) // no buttons @@ -345,18 +326,12 @@ describe('<ShareProjectModal/>', function () { }, ] - fetchMock.get(`/project/${project._id}/tokens`, {}) + fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {}) - renderWithEditorContext(<ShareProjectModal {...modalProps} />, { - scope: { - project: { - ...project, - members, - invites, - publicAccesLevel: 'tokenBased', - }, - }, - }) + renderWithEditorContext( + <ShareProjectModal {...modalProps} />, + createContextProps({ publicAccessLevel: 'tokenBased', members, invites }) + ) const projectOwnerEmail = USER_EMAIL @@ -380,7 +355,7 @@ describe('<ShareProjectModal/>', function () { }) it('resends an invite', async function () { - fetchMock.get(`/project/${project._id}/tokens`, {}) + fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {}) fetchMock.postOnce( 'express:/project/:projectId/invite/:inviteId/resend', 204 @@ -394,15 +369,10 @@ describe('<ShareProjectModal/>', function () { }, ] - renderWithEditorContext(<ShareProjectModal {...modalProps} />, { - scope: { - project: { - ...project, - invites, - publicAccesLevel: 'tokenBased', - }, - }, - }) + renderWithEditorContext( + <ShareProjectModal {...modalProps} />, + createContextProps({ publicAccessLevel: 'tokenBased', invites }) + ) const [, closeButton] = screen.getAllByRole('button', { name: 'Close', @@ -418,7 +388,7 @@ describe('<ShareProjectModal/>', function () { }) it('revokes an invite', async function () { - fetchMock.get(`/project/${project._id}/tokens`, {}) + fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {}) fetchMock.deleteOnce('express:/project/:projectId/invite/:inviteId', 204) const invites = [ @@ -429,15 +399,10 @@ describe('<ShareProjectModal/>', function () { }, ] - renderWithEditorContext(<ShareProjectModal {...modalProps} />, { - scope: { - project: { - ...project, - invites, - publicAccesLevel: 'tokenBased', - }, - }, - }) + renderWithEditorContext( + <ShareProjectModal {...modalProps} />, + createContextProps({ publicAccessLevel: 'tokenBased', invites }) + ) const [, closeButton] = screen.getAllByRole('button', { name: 'Close', @@ -452,7 +417,7 @@ describe('<ShareProjectModal/>', function () { }) it('changes member privileges to read + write', async function () { - fetchMock.get(`/project/${project._id}/tokens`, {}) + fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {}) fetchMock.putOnce('express:/project/:projectId/users/:userId', 204) const members = [ @@ -463,17 +428,12 @@ describe('<ShareProjectModal/>', function () { }, ] - renderWithEditorContext(<ShareProjectModal {...modalProps} />, { - scope: { - project: { - ...project, - members, - publicAccesLevel: 'tokenBased', - }, - }, - }) + renderWithEditorContext( + <ShareProjectModal {...modalProps} />, + createContextProps({ publicAccessLevel: 'tokenBased', members }) + ) - const [, closeButton] = await screen.getAllByRole('button', { + const [, closeButton] = screen.getAllByRole('button', { name: 'Close', }) @@ -493,7 +453,7 @@ describe('<ShareProjectModal/>', function () { }) it('removes a member from the project', async function () { - fetchMock.get(`/project/${project._id}/tokens`, {}) + fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {}) fetchMock.deleteOnce('express:/project/:projectId/users/:userId', 204) const members = [ @@ -504,15 +464,10 @@ describe('<ShareProjectModal/>', function () { }, ] - renderWithEditorContext(<ShareProjectModal {...modalProps} />, { - scope: { - project: { - ...project, - members, - publicAccesLevel: 'tokenBased', - }, - }, - }) + renderWithEditorContext( + <ShareProjectModal {...modalProps} />, + createContextProps({ publicAccessLevel: 'tokenBased', members }) + ) expect( await screen.findAllByText('member-viewer@example.com') @@ -536,7 +491,7 @@ describe('<ShareProjectModal/>', function () { }) it('changes member privileges to owner with confirmation', async function () { - fetchMock.get(`/project/${project._id}/tokens`, {}) + fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {}) fetchMock.postOnce('express:/project/:projectId/transfer-ownership', 204) const members = [ @@ -547,15 +502,10 @@ describe('<ShareProjectModal/>', function () { }, ] - renderWithEditorContext(<ShareProjectModal {...modalProps} />, { - scope: { - project: { - ...project, - members, - publicAccesLevel: 'tokenBased', - }, - }, - }) + renderWithEditorContext( + <ShareProjectModal {...modalProps} />, + createContextProps({ publicAccessLevel: 'tokenBased', members }) + ) await screen.findByText('member-viewer@example.com') expect(screen.queryAllByText('member-viewer@example.com')).to.have.length(1) @@ -585,16 +535,12 @@ describe('<ShareProjectModal/>', function () { }) it('sends invites to input email addresses', async function () { - fetchMock.get(`/project/${project._id}/tokens`, {}) + fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {}) - renderWithEditorContext(<ShareProjectModal {...modalProps} />, { - scope: { - project: { - ...project, - publicAccesLevel: 'tokenBased', - }, - }, - }) + renderWithEditorContext( + <ShareProjectModal {...modalProps} />, + createContextProps({ publicAccessLevel: 'tokenBased' }) + ) const [inputElement] = await screen.findAllByLabelText('Add people') @@ -679,25 +625,24 @@ describe('<ShareProjectModal/>', function () { }) it('displays a message when the collaborator limit is reached', async function () { - fetchMock.get(`/project/${project._id}/tokens`, {}) + fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {}) fetchMock.post( '/event/paywall-prompt', {}, { body: { 'paywall-type': 'project-sharing' } } ) - renderWithEditorContext(<ShareProjectModal {...modalProps} />, { - scope: { - project: { - ...project, - publicAccesLevel: 'tokenBased', - features: { - collaborators: 0, - compileGroup: 'standard', - }, + renderWithEditorContext( + <ShareProjectModal {...modalProps} />, + createContextProps({ + publicAccessLevel: 'tokenBased', + features: { + collaborators: 0, + compileGroup: 'standard', + trackChangesVisible: true, }, - }, - }) + }) + ) await screen.findByText('Add more collaborators') @@ -717,23 +662,22 @@ describe('<ShareProjectModal/>', function () { }) it('counts reviewers towards the collaborator limit', async function () { - renderWithEditorContext(<ShareProjectModal {...modalProps} />, { - scope: { - project: { - ...project, - features: { - collaborators: 1, - }, - members: [ - { - _id: 'reviewer-id', - email: 'reviewer@example.com', - privileges: 'review', - }, - ], + renderWithEditorContext( + <ShareProjectModal {...modalProps} />, + createContextProps({ + features: { + collaborators: 1, + trackChangesVisible: true, }, - }, - }) + members: [ + { + _id: 'reviewer-id', + email: 'reviewer@example.com', + privileges: 'review', + }, + ], + }) + ) await screen.findByText('Add more collaborators') @@ -754,16 +698,14 @@ describe('<ShareProjectModal/>', function () { }) it('handles server error responses', async function () { - fetchMock.get(`/project/${project._id}/tokens`, {}) + fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {}) - renderWithEditorContext(<ShareProjectModal {...modalProps} />, { - scope: { - project: { - ...project, - publicAccesLevel: 'tokenBased', - }, - }, - }) + renderWithEditorContext( + <ShareProjectModal {...modalProps} />, + createContextProps({ + publicAccessLevel: 'tokenBased', + }) + ) // loading contacts await waitFor(() => { @@ -815,14 +757,25 @@ describe('<ShareProjectModal/>', function () { }) it('handles switching between access levels', async function () { - fetchMock.get(`/project/${project._id}/tokens`, {}) + fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {}) fetchMock.post('express:/project/:projectId/settings/admin', 204) - renderWithEditorContext(<ShareProjectModal {...modalProps} />, { - scope: { - project: { ...project, publicAccesLevel: 'private' }, - }, - }) + let setPublicAccessLevel = function () {} + + function WrappedModal() { + const { updateProject } = useProjectContext() + setPublicAccessLevel = publicAccessLevel => { + updateProject({ publicAccessLevel }) + } + return <ShareProjectModal {...modalProps} /> + } + + renderWithEditorContext( + <WrappedModal />, + createContextProps({ + publicAccessLevel: 'private', + }) + ) await screen.findByText('Link sharing is off') @@ -837,13 +790,10 @@ describe('<ShareProjectModal/>', function () { publicAccessLevel: 'tokenBased', }) - // NOTE: updating the scoped project data manually, - // as the project data is usually updated via the websocket connection - window.overleaf.unstable.store.set('project', { - ...project, - publicAccesLevel: 'tokenBased', - }) - // watchCallbacks.project({ ...project, publicAccesLevel: 'tokenBased' }) + // NOTE: the project data is usually updated via the websocket connection + // but we can't do that so we're doing it via the project context, which is + // exposed in a hacky way here + setPublicAccessLevel('tokenBased') await screen.findByText('Link sharing is on') const disableButton = await screen.findByRole('button', { @@ -857,21 +807,16 @@ describe('<ShareProjectModal/>', function () { publicAccessLevel: 'private', }) - // NOTE: updating the scoped project data manually, - // as the project data is usually updated via the websocket connection - window.overleaf.unstable.store.set('project', { - ...project, - publicAccesLevel: 'private', - }) - // watchCallbacks.project({ ...project, publicAccesLevel: 'private' }) + setPublicAccessLevel('private') await screen.findByText('Link sharing is off') }) it('avoids selecting unmatched contact', async function () { - renderWithEditorContext(<ShareProjectModal {...modalProps} />, { - scope: { project }, - }) + renderWithEditorContext( + <ShareProjectModal {...modalProps} />, + createContextProps() + ) const [inputElement] = await screen.findAllByLabelText('Add people') @@ -923,9 +868,10 @@ describe('<ShareProjectModal/>', function () { }) it('selects contact by typing the entire email and blurring the input', async function () { - renderWithEditorContext(<ShareProjectModal {...modalProps} />, { - scope: { project }, - }) + renderWithEditorContext( + <ShareProjectModal {...modalProps} />, + createContextProps() + ) const [inputElement] = await screen.findAllByLabelText('Add people') @@ -958,9 +904,10 @@ describe('<ShareProjectModal/>', function () { }) it('selects contact by typing a partial email and selecting the suggestion', async function () { - renderWithEditorContext(<ShareProjectModal {...modalProps} />, { - scope: { project }, - }) + renderWithEditorContext( + <ShareProjectModal {...modalProps} />, + createContextProps() + ) const [inputElement] = await screen.findAllByLabelText('Add people') @@ -992,9 +939,10 @@ describe('<ShareProjectModal/>', function () { }) it('allows an email address to be selected, removed, then re-added', async function () { - renderWithEditorContext(<ShareProjectModal {...modalProps} />, { - scope: { project }, - }) + renderWithEditorContext( + <ShareProjectModal {...modalProps} />, + createContextProps() + ) const [inputElement] = await screen.findAllByLabelText('Add people') diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-autocomplete.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-autocomplete.spec.tsx index 932482205f..fc8831efd5 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-autocomplete.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-autocomplete.spec.tsx @@ -64,7 +64,6 @@ describe('autocomplete', { scrollBehavior: false }, function () { ] const scope = mockScope() - scope.project.rootFolder = rootFolder cy.mount( <TestContainer> @@ -446,7 +445,6 @@ describe('autocomplete', { scrollBehavior: false }, function () { ] const scope = mockScope() - scope.project.rootFolder = rootFolder cy.mount( <TestContainer> @@ -910,7 +908,6 @@ describe('autocomplete', { scrollBehavior: false }, function () { ] const scope = mockScope() - scope.project.rootFolder = rootFolder cy.mount( <TestContainer> diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-figure-modal.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-figure-modal.spec.tsx index 0d80f9bde8..22a99e1fc7 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-figure-modal.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-figure-modal.spec.tsx @@ -1,10 +1,16 @@ import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' -import { EditorProviders } from '../../../helpers/editor-providers' +import { + EditorProviders, + makeEditorPropertiesProvider, + makeProjectProvider, + USER_ID, +} from '../../../helpers/editor-providers' import { mockScope, rootFolderId } from '../helpers/mock-scope' import { FC } from 'react' import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path' import { TestContainer } from '../helpers/test-container' import getMeta from '@/utils/meta' +import { mockProject } from '../helpers/mock-project' const clickToolbarButton = (text: string) => { cy.findByLabelText(text).click() @@ -41,7 +47,11 @@ describe('<FigureModal />', function () { function mount() { const content = '' const scope = mockScope(content) - scope.editor.showVisual = true + const project = mockProject({ + projectOwner: { + _id: USER_ID, + }, + }) const FileTreePathProvider: FC<React.PropsWithChildren> = ({ children, @@ -63,7 +73,17 @@ describe('<FigureModal />', function () { cy.mount( <TestContainer> - <EditorProviders scope={scope} providers={{ FileTreePathProvider }}> + <EditorProviders + scope={scope} + providers={{ + FileTreePathProvider, + ProjectProvider: makeProjectProvider(project), + EditorPropertiesProvider: makeEditorPropertiesProvider({ + showVisual: true, + showSymbolPalette: false, + }), + }} + > <CodemirrorEditor /> </EditorProviders> </TestContainer> @@ -106,9 +126,11 @@ describe('<FigureModal />', function () { matchUrl(`/project/test-project/upload?folder_id=${rootFolderId}`) ) + // Note that we have to include the 'edit' text from the edit button's + // icon, which is literal text in the document cy.get('.cm-content').should( 'have.text', - '\\begin{figure} \\centering \\caption{Enter Caption} 🏷fig:enter-label\\end{figure}' + '\\begin{figure} \\centeringedit \\caption{Enter Caption} 🏷fig:enter-label\\end{figure}' ) }) @@ -152,9 +174,12 @@ describe('<FigureModal />', function () { cy.findByText('frog.jpg').click() }) cy.findByRole('button', { name: 'Insert figure' }).click() + + // Note that we have to include the 'edit' text from the edit button's + // icon, which is literal text in the document cy.get('.cm-content').should( 'have.text', - '\\begin{figure} \\centering \\caption{Enter Caption} 🏷fig:enter-label\\end{figure}' + '\\begin{figure} \\centeringedit \\caption{Enter Caption} 🏷fig:enter-label\\end{figure}' ) }) }) @@ -238,9 +263,11 @@ describe('<FigureModal />', function () { }, }) + // Note that we have to include the 'edit' text from the edit button's + // icon, which is literal text in the document cy.get('.cm-content').should( 'have.text', - '\\begin{figure} \\centering \\caption{Enter Caption} 🏷fig:enter-label\\end{figure}' + '\\begin{figure} \\centeringedit \\caption{Enter Caption} 🏷fig:enter-label\\end{figure}' ) }) @@ -265,9 +292,11 @@ describe('<FigureModal />', function () { }, }) + // Note that we have to include the 'edit' text from the edit button's + // icon, which is literal text in the document cy.get('.cm-content').should( 'have.text', - '\\begin{figure} \\centering \\caption{Enter Caption} 🏷fig:enter-label\\end{figure}' + '\\begin{figure} \\centeringedit \\caption{Enter Caption} 🏷fig:enter-label\\end{figure}' ) }) }) @@ -409,9 +438,11 @@ describe('<FigureModal />', function () { }, }) + // Note that we have to include the 'edit' text from the edit button's + // icon, which is literal text in the document cy.get('.cm-content').should( 'have.text', - '\\begin{figure} \\centering \\caption{Enter Caption} 🏷fig:enter-label\\end{figure}' + '\\begin{figure} \\centeringedit \\caption{Enter Caption} 🏷fig:enter-label\\end{figure}' ) }) @@ -435,9 +466,12 @@ describe('<FigureModal />', function () { // If caption is selected then typing will replace the whole caption cy.focused().type('My caption') + + // Note that we have to include the 'edit' text from the edit button's + // icon, which is literal text in the document cy.get('.cm-content').should( 'have.text', - '\\begin{figure} \\centering \\caption{My caption} 🏷fig:enter-label\\end{figure}' + '\\begin{figure} \\centeringedit \\caption{My caption} 🏷fig:enter-label\\end{figure}' ) }) @@ -462,9 +496,12 @@ describe('<FigureModal />', function () { // If label is selected then typing will replace the whole label cy.focused().type('fig:my-label') + + // Note that we have to include the 'edit' text from the edit button's + // icon, which is literal text in the document cy.get('.cm-content').should( 'have.text', - '\\begin{figure} \\centering \\label{fig:my-label}\\end{figure}' + '\\begin{figure} \\centeringedit \\label{fig:my-label}\\end{figure}' ) }) @@ -484,16 +521,18 @@ describe('<FigureModal />', function () { }, }) + // Note that we have to include the 'edit' text from the edit button's + // icon, which is literal text in the document cy.get('.cm-content').should( 'have.text', - '\\begin{figure} \\centering\\end{figure}' + '\\begin{figure} \\centeringedit\\end{figure}' ) cy.focused().type('Some more text') cy.get('.cm-content').should( 'have.text', - '\\begin{figure} \\centering\\end{figure}Some more text' + '\\begin{figure} \\centeringedit\\end{figure}Some more text' ) }) }) diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-spellchecker-client.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-spellchecker-client.spec.tsx index ee96d76d53..3677884a0c 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-spellchecker-client.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-spellchecker-client.spec.tsx @@ -1,9 +1,13 @@ import { mockScope } from '../helpers/mock-scope' -import { EditorProviders } from '../../../helpers/editor-providers' +import { + EditorProviders, + makeProjectProvider, +} from '../../../helpers/editor-providers' import CodeMirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' import { TestContainer } from '../helpers/test-container' import forEach from 'mocha-each' import PackageVersions from '../../../../../app/src/infrastructure/PackageVersions' +import { mockProject } from '../helpers/mock-project' const languages = [ { code: 'af', dic: 'af_ZA', name: 'Afrikaans' }, @@ -125,11 +129,14 @@ forEach(Object.keys(suggestions)).describe( cy.interceptEvents() const scope = mockScope(content) - scope.project.spellCheckLanguage = spellCheckLanguage + const project = mockProject({ spellCheckLanguage }) cy.mount( <TestContainer> - <EditorProviders scope={scope}> + <EditorProviders + scope={scope} + providers={{ ProjectProvider: makeProjectProvider(project) }} + > <CodeMirrorEditor /> </EditorProviders> </TestContainer> diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-table-generator.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-table-generator.spec.tsx index 34f26f02cb..18422c1ff0 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-table-generator.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-table-generator.spec.tsx @@ -1,6 +1,9 @@ // Needed since eslint gets confused by mocha-each /* eslint-disable mocha/prefer-arrow-callback */ -import { EditorProviders } from '../../../helpers/editor-providers' +import { + EditorProviders, + makeEditorPropertiesProvider, +} from '../../../helpers/editor-providers' import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' import { mockScope } from '../helpers/mock-scope' import forEach from 'mocha-each' @@ -14,11 +17,18 @@ const mountEditor = (content: string | string[]) => { content = '\n' + content } const scope = mockScope(content) - scope.editor.showVisual = true cy.viewport(1000, 800) cy.mount( <TestContainer style={{ width: 1000, height: 800 }}> - <EditorProviders scope={scope}> + <EditorProviders + scope={scope} + providers={{ + EditorPropertiesProvider: makeEditorPropertiesProvider({ + showVisual: true, + showSymbolPalette: false, + }), + }} + > <CodemirrorEditor /> </EditorProviders> </TestContainer> diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-command-tooltip.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-command-tooltip.spec.tsx index 28f1363829..7ffd4f0fcd 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-command-tooltip.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-command-tooltip.spec.tsx @@ -1,15 +1,30 @@ -import { EditorProviders } from '../../../helpers/editor-providers' +import { + EditorProviders, + makeEditorPropertiesProvider, + makeProjectProvider, +} from '../../../helpers/editor-providers' import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' import { mockScope } from '../helpers/mock-scope' import { TestContainer } from '../helpers/test-container' +import { mockProject } from '../helpers/mock-project' const mountEditor = (content: string) => { const scope = mockScope(content) - scope.editor.showVisual = true + + const project = mockProject() cy.mount( <TestContainer> - <EditorProviders scope={scope}> + <EditorProviders + scope={scope} + providers={{ + ProjectProvider: makeProjectProvider(project), + EditorPropertiesProvider: makeEditorPropertiesProvider({ + showVisual: true, + showSymbolPalette: false, + }), + }} + > <CodemirrorEditor /> </EditorProviders> </TestContainer> diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-floats.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-floats.spec.tsx index 0e406418fa..3433205a9f 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-floats.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-floats.spec.tsx @@ -1,15 +1,25 @@ -import { EditorProviders } from '../../../helpers/editor-providers' +import { + EditorProviders, + makeEditorPropertiesProvider, +} from '../../../helpers/editor-providers' import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' import { mockScope } from '../helpers/mock-scope' import { TestContainer } from '../helpers/test-container' const mountEditor = (content: string) => { const scope = mockScope(content) - scope.editor.showVisual = true cy.mount( <TestContainer> - <EditorProviders scope={scope}> + <EditorProviders + scope={scope} + providers={{ + EditorPropertiesProvider: makeEditorPropertiesProvider({ + showVisual: true, + showSymbolPalette: false, + }), + }} + > <CodemirrorEditor /> </EditorProviders> </TestContainer> diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-list.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-list.spec.tsx index f2ae1df68e..ca7efaca04 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-list.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-list.spec.tsx @@ -1,16 +1,30 @@ -import { EditorProviders } from '../../../helpers/editor-providers' +import { + EditorProviders, + makeEditorPropertiesProvider, + makeProjectProvider, +} from '../../../helpers/editor-providers' import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' import { mockScope } from '../helpers/mock-scope' import { TestContainer } from '../helpers/test-container' import { isMac } from '@/shared/utils/os' +import { mockProject } from '../helpers/mock-project' const mountEditor = (content: string) => { const scope = mockScope(content) - scope.editor.showVisual = true + const project = mockProject() cy.mount( <TestContainer> - <EditorProviders scope={scope}> + <EditorProviders + scope={scope} + providers={{ + ProjectProvider: makeProjectProvider(project), + EditorPropertiesProvider: makeEditorPropertiesProvider({ + showVisual: true, + showSymbolPalette: false, + }), + }} + > <CodemirrorEditor /> </EditorProviders> </TestContainer> diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-paste-html.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-paste-html.spec.tsx index 9220d6ccdf..8173cefb9d 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-paste-html.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-paste-html.spec.tsx @@ -1,4 +1,7 @@ -import { EditorProviders } from '../../../helpers/editor-providers' +import { + EditorProviders, + makeEditorPropertiesProvider, +} from '../../../helpers/editor-providers' import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' import { mockScope } from '../helpers/mock-scope' import { TestContainer } from '../helpers/test-container' @@ -7,11 +10,18 @@ const menuIconsText = 'content_copyexpand_more' const mountEditor = (content = '') => { const scope = mockScope(content) - scope.editor.showVisual = true cy.mount( <TestContainer> - <EditorProviders scope={scope}> + <EditorProviders + scope={scope} + providers={{ + EditorPropertiesProvider: makeEditorPropertiesProvider({ + showVisual: true, + showSymbolPalette: false, + }), + }} + > <CodemirrorEditor /> </EditorProviders> </TestContainer> diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-readonly.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-readonly.spec.tsx index 7faa740a67..5e05429212 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-readonly.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-readonly.spec.tsx @@ -1,5 +1,8 @@ import { mockScope } from '../helpers/mock-scope' -import { EditorProviders } from '../../../helpers/editor-providers' +import { + EditorProviders, + makeEditorPropertiesProvider, +} from '../../../helpers/editor-providers' import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' import { FC } from 'react' import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path' @@ -43,13 +46,19 @@ const mountEditor = (content: string) => { const scope = mockScope(content) scope.permissions.write = false scope.permissions.trackedWrite = false - scope.editor.showVisual = true cy.mount( <TestContainer> <EditorProviders scope={scope} - providers={{ FileTreePathProvider, PermissionsProvider }} + providers={{ + FileTreePathProvider, + PermissionsProvider, + EditorPropertiesProvider: makeEditorPropertiesProvider({ + showVisual: true, + showSymbolPalette: false, + }), + }} > <CodemirrorEditor /> </EditorProviders> diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx index 63dd3da5b1..542f1ce662 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx @@ -1,4 +1,7 @@ -import { EditorProviders } from '../../../helpers/editor-providers' +import { + EditorProviders, + makeEditorPropertiesProvider, +} from '../../../helpers/editor-providers' import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' import { mockScope } from '../helpers/mock-scope' import { TestContainer } from '../helpers/test-container' @@ -18,11 +21,18 @@ const clickToolbarButton = (name: string) => { const mountEditor = (content: string) => { const scope = mockScope(content) - scope.editor.showVisual = true cy.mount( <TestContainer> - <EditorProviders scope={scope}> + <EditorProviders + scope={scope} + providers={{ + EditorPropertiesProvider: makeEditorPropertiesProvider({ + showVisual: true, + showSymbolPalette: false, + }), + }} + > <CodemirrorEditor /> </EditorProviders> </TestContainer> diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-tooltips.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-tooltips.spec.tsx index 7dcc10d72c..2afed2b813 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-tooltips.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-tooltips.spec.tsx @@ -1,5 +1,8 @@ import { mockScope } from '../helpers/mock-scope' -import { EditorProviders } from '../../../helpers/editor-providers' +import { + EditorProviders, + makeEditorPropertiesProvider, +} from '../../../helpers/editor-providers' import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' import { TestContainer } from '../helpers/test-container' @@ -11,11 +14,18 @@ describe('<CodeMirrorEditor/> tooltips in Visual mode', function () { cy.interceptEvents() const scope = mockScope('\n\n\n') - scope.editor.showVisual = true cy.mount( <TestContainer> - <EditorProviders scope={scope}> + <EditorProviders + scope={scope} + providers={{ + EditorPropertiesProvider: makeEditorPropertiesProvider({ + showVisual: true, + showSymbolPalette: false, + }), + }} + > <CodemirrorEditor /> </EditorProviders> </TestContainer> diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx index 7a9acebf07..45f68f359b 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx @@ -1,7 +1,10 @@ // Needed since eslint gets confused by mocha-each /* eslint-disable mocha/prefer-arrow-callback */ import { FC } from 'react' -import { EditorProviders } from '../../../helpers/editor-providers' +import { + EditorProviders, + makeEditorPropertiesProvider, +} from '../../../helpers/editor-providers' import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' import { mockScope } from '../helpers/mock-scope' import forEach from 'mocha-each' @@ -19,7 +22,6 @@ describe('<CodeMirrorEditor/> in Visual mode', function () { const content = '\n'.repeat(3) const scope = mockScope(content) - scope.editor.showVisual = true const FileTreePathProvider: FC<React.PropsWithChildren> = ({ children, @@ -41,7 +43,16 @@ describe('<CodeMirrorEditor/> in Visual mode', function () { cy.mount( <TestContainer> - <EditorProviders scope={scope} providers={{ FileTreePathProvider }}> + <EditorProviders + scope={scope} + providers={{ + FileTreePathProvider, + EditorPropertiesProvider: makeEditorPropertiesProvider({ + showVisual: true, + showSymbolPalette: false, + }), + }} + > <CodemirrorEditor /> </EditorProviders> </TestContainer> diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor.spec.tsx index 40693c2ec1..b64e66734f 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor.spec.tsx @@ -7,7 +7,9 @@ import { activeEditorLine } from '../helpers/active-editor-line' import { TestContainer } from '../helpers/test-container' import customLocalStorage from '@/infrastructure/local-storage' import { OnlineUsersContext } from '@/features/ide-react/context/online-users-context' -import { FC } from 'react' +import { LocalCompileContext } from '@/shared/context/local-compile-context' +import type { FC, PropsWithChildren } from 'react' +import type { Annotation } from '../../../../../types/annotation' describe('<CodeMirrorEditor/>', { scrollBehavior: false }, function () { beforeEach(function () { @@ -64,27 +66,39 @@ describe('<CodeMirrorEditor/>', { scrollBehavior: false }, function () { it('renders annotations in the gutter', function () { const scope = mockScope() - scope.pdf.logEntryAnnotations = { + const logEntryAnnotations: Record<string, Annotation[]> = { [docId]: [ { + id: '1', + entryIndex: 1, row: 20, type: 'error', text: 'Another error', + firstOnLine: true, }, { + id: '2', + entryIndex: 2, row: 19, type: 'error', text: 'An error', + firstOnLine: true, }, { + id: '3', + entryIndex: 3, row: 20, type: 'warning', text: 'A warning on the same line', + firstOnLine: false, }, { + id: '4', + entryIndex: 4, row: 25, type: 'warning', text: 'Another warning', + firstOnLine: true, }, ], } @@ -93,9 +107,19 @@ describe('<CodeMirrorEditor/>', { scrollBehavior: false }, function () { cy.clock() + const LocalCompileProvider: FC<PropsWithChildren> = ({ children }) => ( + // @ts-expect-error: not entering all the values for LocalCompileContext + <LocalCompileContext.Provider value={{ logEntryAnnotations }}> + {children} + </LocalCompileContext.Provider> + ) cy.mount( <TestContainer> - <EditorProviders scope={scope} userSettings={userSettings}> + <EditorProviders + scope={scope} + userSettings={userSettings} + providers={{ LocalCompileProvider }} + > <CodeMirrorEditor /> </EditorProviders> </TestContainer> @@ -598,7 +622,7 @@ describe('<CodeMirrorEditor/>', { scrollBehavior: false }, function () { const rect = selection.getRangeAt(0).getBoundingClientRect() expect(Math.round(rect.top)).to.be.gte(100) - expect(Math.round(rect.left)).to.be.gte(90) + expect(Math.round(rect.left)).to.be.gte(80) }) }) }) diff --git a/services/web/test/frontend/features/source-editor/helpers/mock-project.ts b/services/web/test/frontend/features/source-editor/helpers/mock-project.ts new file mode 100644 index 0000000000..f9848d1a96 --- /dev/null +++ b/services/web/test/frontend/features/source-editor/helpers/mock-project.ts @@ -0,0 +1,78 @@ +import { docId } from './mock-doc' +import { Folder } from '../../../../../types/folder' +import { UserId } from '../../../../../types/user' +import { ProjectCompiler } from '../../../../../types/project-settings' + +export const rootFolderId = '012345678901234567890123' +export const figuresFolderId = '123456789012345678901234' +export const figureId = '234567890123456789012345' +export const mockProject = ({ + projectFeatures = {}, + projectOwner = undefined, + spellCheckLanguage = 'en', + rootFolder = null, +}: any = {}) => { + return { + _id: 'test-project', + name: 'Test Project', + spellCheckLanguage, + rootDocId: '_root_doc_id', + rootFolder: + rootFolder || + ([ + { + _id: rootFolderId, + name: 'rootFolder', + docs: [ + { + _id: docId, + name: 'test.tex', + }, + ], + folders: [ + { + _id: figuresFolderId, + name: 'figures', + docs: [ + { + _id: 'fake-nested-doc-id', + name: 'foo.tex', + }, + ], + folders: [], + fileRefs: [ + { + _id: figureId, + name: 'frog.jpg', + hash: '42', + }, + { + _id: 'fake-figure-id', + name: 'unicorn.png', + hash: '43', + }, + ], + }, + ], + fileRefs: [], + }, + ] as Folder[]), + features: { + trackChanges: true, + ...projectFeatures, + }, + compiler: 'pdflatex' as ProjectCompiler, + imageName: 'texlive-full:2024.1', + trackChangesState: false, + invites: [], + members: [], + owner: projectOwner || { + _id: '124abd' as UserId, + email: 'owner@example.com', + first_name: 'Test', + last_name: 'Owner', + privileges: 'owner', + signUpDate: new Date('2025-07-07').toISOString(), + }, + } +} diff --git a/services/web/test/frontend/features/source-editor/helpers/mock-scope.ts b/services/web/test/frontend/features/source-editor/helpers/mock-scope.ts index 621bdecd3c..fd19ce4f85 100644 --- a/services/web/test/frontend/features/source-editor/helpers/mock-scope.ts +++ b/services/web/test/frontend/features/source-editor/helpers/mock-scope.ts @@ -1,96 +1,31 @@ import { docId, mockDoc } from './mock-doc' import { sleep } from '../../../helpers/sleep' -import { Folder } from '../../../../../types/folder' export const rootFolderId = '012345678901234567890123' -export const figuresFolderId = '123456789012345678901234' -export const figureId = '234567890123456789012345' export const mockScope = ( content?: string, - { - docOptions = {}, - projectFeatures = {}, - permissions = {}, - projectOwner = undefined, - }: any = {} + { docOptions = {}, permissions = {} }: any = {} ) => { return { editor: { sharejs_doc: mockDoc(content, docOptions), - open_doc_name: 'test.tex', - open_doc_id: docId, - showVisual: false, + openDocName: 'test.tex', + currentDocumentId: docId, wantTrackChanges: false, }, pdf: { logEntryAnnotations: {}, }, - project: { - _id: 'test-project', - name: 'Test Project', - spellCheckLanguage: 'en', - rootFolder: [ - { - _id: rootFolderId, - name: 'rootFolder', - docs: [ - { - _id: docId, - name: 'test.tex', - }, - ], - folders: [ - { - _id: figuresFolderId, - name: 'figures', - docs: [ - { - _id: 'fake-nested-doc-id', - name: 'foo.tex', - }, - ], - folders: [], - fileRefs: [ - { - _id: figureId, - name: 'frog.jpg', - hash: '42', - }, - { - _id: 'fake-figure-id', - name: 'unicorn.png', - hash: '43', - }, - ], - }, - ], - fileRefs: [], - }, - ] as Folder[], - features: { - trackChanges: true, - ...projectFeatures, - }, - trackChangesState: {}, - members: [], - owner: projectOwner, - }, permissions: { comment: true, trackedWrite: true, write: true, ...permissions, }, - ui: { - reviewPanelOpen: false, - }, toggleReviewPanel: cy.stub(), toggleTrackChangesForEveryone: cy.stub(), refreshResolvedCommentsDropdown: cy.stub(() => sleep(1000)), onlineUserCursorHighlights: {}, permissionsLevel: 'owner', - $on: cy.stub().log(false), - $broadcast: cy.stub().log(false), - $emit: cy.stub().log(false), } } diff --git a/services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx b/services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx index baada41976..f5802a2368 100644 --- a/services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx +++ b/services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx @@ -516,6 +516,35 @@ describe('<ActiveSubscription />', function () { }) }) + describe('contact support for paused subscription with 0 remaining cycles', function () { + beforeEach(function () { + this.locationWrapperSandbox = sinon.createSandbox() + this.locationWrapperStub = this.locationWrapperSandbox.stub(location) + }) + + afterEach(function () { + this.locationWrapperSandbox.restore() + }) + + it('redirects to contact page when cancel button clicked', function () { + const pausedSubscription = cloneDeep(annualActiveSubscription) + pausedSubscription.payment.state = 'paused' + pausedSubscription.payment.remainingPauseCycles = 0 + + renderActiveSubscription(pausedSubscription) + + const button = screen.getByRole('button', { + name: 'Cancel your subscription', + }) + fireEvent.click(button) + + expect(sendMBSpy).to.be.calledOnceWith( + 'subscription-page-cancel-button-click' + ) + expect(this.locationWrapperStub.assign).to.be.calledOnceWith('/contact') + }) + }) + describe('group plans', function () { it('does not show "Change plan" option for group plans', function () { renderActiveSubscription(groupActiveSubscription) diff --git a/services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx b/services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx index 7ffe7760d8..bbcb955d2d 100644 --- a/services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx +++ b/services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx @@ -238,11 +238,11 @@ describe('<ChangePlanModal />', function () { screen.getByRole('button', { name: 'Processing…' }) - await screen.findByText('Sorry, something went wrong. ', { exact: false }) - await screen.findByText('Please try again. ', { exact: false }) - await screen.findByText('If the problem continues please contact us.', { - exact: false, - }) + await screen.findAllByText( + (content, element) => + element?.textContent === + 'Sorry, something went wrong. Please try again. If the problem continues please contact us.' + ) expect( within(screen.getByRole('dialog')) diff --git a/services/web/test/frontend/features/subscription/components/shared/payment-error-notification.test.tsx b/services/web/test/frontend/features/subscription/components/shared/payment-error-notification.test.tsx new file mode 100644 index 0000000000..e88551b20b --- /dev/null +++ b/services/web/test/frontend/features/subscription/components/shared/payment-error-notification.test.tsx @@ -0,0 +1,103 @@ +import { expect } from 'chai' +import { render, screen } from '@testing-library/react' +import PaymentErrorNotification from '../../../../../../frontend/js/features/subscription/components/shared/payment-error-notification' +import { FetchError } from '@/infrastructure/fetch-json' +import { billingPortalUrl } from '@/features/subscription/data/subscription-url' + +describe('<PaymentErrorNotification />', function () { + it('does not render if error is missing', function () { + render(<PaymentErrorNotification error={null} />) + + expect(screen.queryByRole('link')).to.be.null + }) + + it('renders a generic error if adviceCode is missing', function () { + const error = { data: { adviceCode: null } } as FetchError + + render(<PaymentErrorNotification error={error} />) + + expect( + screen.queryAllByText( + (content, element) => + element?.textContent === + 'Sorry, something went wrong. Please try again. If the problem continues please contact us.' + ).length + ).to.be.greaterThan(0) + + const link = screen.queryByRole('link') + expect(link).to.exist + expect(link?.getAttribute('href')).to.equal('/contact') + }) + + it('renders an error if adviceCode is missing but clientSecret is present', function () { + const error = { data: { clientSecret: 'cs_12345' } } as FetchError + + render(<PaymentErrorNotification error={error} />) + + expect( + screen.queryAllByText( + (content, element) => + element?.textContent === + 'We couldn’t complete your payment because authentication wasn’t successful. Please try again or choose a different payment method. If the problem continues please contact us.' + ).length + ).to.be.greaterThan(0) + + const link = screen.queryByRole('link') + expect(link).to.exist + expect(link?.getAttribute('href')).to.equal('/contact') + }) + + it('renders a error to try again if adviceCode is try_again_later', function () { + const error = { data: { adviceCode: 'try_again_later' } } as FetchError + + render(<PaymentErrorNotification error={error} />) + + expect( + screen.queryAllByText( + (content, element) => + element?.textContent === + 'We were unable to process your payment. Please try again later or contact us for assistance.' + ).length + ).to.be.greaterThan(0) + + const link = screen.queryByRole('link') + expect(link).to.exist + expect(link?.getAttribute('href')).to.equal('/contact') + }) + + it('renders an error to update payment method if adviceCode do_not_try_again', function () { + const error = { data: { adviceCode: 'do_not_try_again' } } as FetchError + + render(<PaymentErrorNotification error={error} />) + + expect( + screen.queryAllByText( + (content, element) => + element?.textContent === + 'Your payment was declined. Please update your billing information and try again.' + ).length + ).to.be.greaterThan(0) + + const link = screen.queryByRole('link') + expect(link).to.exist + expect(link?.getAttribute('href')).to.equal(billingPortalUrl) + }) + + it('renders an error to update payment method if adviceCode confirm_card_data', function () { + const error = { data: { adviceCode: 'confirm_card_data' } } as FetchError + + render(<PaymentErrorNotification error={error} />) + + expect( + screen.queryAllByText( + (content, element) => + element?.textContent === + 'Your payment was declined. Please update your billing information and try again.' + ).length + ).to.be.greaterThan(0) + + const link = screen.queryByRole('link') + expect(link).to.exist + expect(link?.getAttribute('href')).to.equal(billingPortalUrl) + }) +}) diff --git a/services/web/test/frontend/helpers/editor-providers.jsx b/services/web/test/frontend/helpers/editor-providers.jsx deleted file mode 100644 index 1fe143a8e3..0000000000 --- a/services/web/test/frontend/helpers/editor-providers.jsx +++ /dev/null @@ -1,217 +0,0 @@ -// Disable prop type checks for test harnesses -/* eslint-disable react/prop-types */ -import { merge } from 'lodash' -import { SocketIOMock } from '@/ide/connection/SocketIoShim' -import { IdeContext } from '@/shared/context/ide-context' -import React, { useEffect, useState } from 'react' -import { - createReactScopeValueStore, - IdeReactContext, -} from '@/features/ide-react/context/ide-react-context' -import { IdeEventEmitter } from '@/features/ide-react/create-ide-event-emitter' -import { ReactScopeEventEmitter } from '@/features/ide-react/scope-event-emitter/react-scope-event-emitter' -import { ConnectionContext } from '@/features/ide-react/context/connection-context' -import { ReactContextRoot } from '@/features/ide-react/context/react-context-root' - -// these constants can be imported in tests instead of -// using magic strings -export const PROJECT_ID = 'project123' -export const PROJECT_NAME = 'project-name' -export const USER_ID = '123abd' -export const USER_EMAIL = 'testuser@example.com' - -const defaultUserSettings = { - pdfViewer: 'pdfjs', - fontSize: 12, - fontFamily: 'monaco', - lineHeight: 'normal', - editorTheme: 'textmate', - overallTheme: '', - mode: 'default', - autoComplete: true, - autoPairDelimiters: true, - trackChanges: true, - syntaxValidation: false, - mathPreview: true, -} - -export function EditorProviders({ - user = { id: USER_ID, email: USER_EMAIL }, - projectId = PROJECT_ID, - projectOwner = { - _id: '124abd', - email: 'owner@example.com', - }, - rootDocId = '_root_doc_id', - imageName = 'texlive-full:2024.1', - compiler = 'pdflatex', - socket = new SocketIOMock(), - isRestrictedTokenMember = false, - scope: defaultScope = {}, - features = { - referencesSearch: true, - }, - projectFeatures = features, - permissionsLevel = 'owner', - children, - rootFolder = [ - { - _id: 'root-folder-id', - name: 'rootFolder', - docs: [ - { - _id: '_root_doc_id', - name: 'main.tex', - }, - ], - folders: [], - fileRefs: [], - }, - ], - ui = { view: 'editor', pdfLayout: 'sideBySide', chatOpen: true }, - userSettings = {}, - providers = {}, -}) { - window.metaAttributesCache.set( - 'ol-gitBridgePublicBaseUrl', - 'https://git.overleaf.test' - ) - window.metaAttributesCache.set( - 'ol-isRestrictedTokenMember', - isRestrictedTokenMember - ) - window.metaAttributesCache.set( - 'ol-userSettings', - merge({}, defaultUserSettings, userSettings) - ) - - window.metaAttributesCache.set('ol-capabilities', ['chat', 'dropbox']) - - const scope = merge( - { - user, - editor: { - sharejs_doc: { - doc_id: 'test-doc', - getSnapshot: () => 'some doc content', - hasBufferedOps: () => false, - on: () => {}, - off: () => {}, - leaveAndCleanUpPromise: async () => {}, - }, - }, - project: { - _id: projectId, - name: PROJECT_NAME, - owner: projectOwner, - features: projectFeatures, - rootDocId, - rootFolder, - imageName, - compiler, - }, - ui, - permissionsLevel, - }, - defaultScope - ) - - // Add details for useUserContext - window.metaAttributesCache.set('ol-user', { ...user, features }) - window.metaAttributesCache.set('ol-project_id', projectId) - - return ( - <ReactContextRoot - providers={{ - ConnectionProvider: makeConnectionProvider(socket), - IdeReactProvider: makeIdeReactProvider(scope, socket), - ...providers, - }} - > - {children} - </ReactContextRoot> - ) -} - -const makeConnectionProvider = socket => { - const ConnectionProvider = ({ children }) => { - const [value] = useState(() => ({ - socket, - connectionState: { - readyState: WebSocket.OPEN, - forceDisconnected: false, - inactiveDisconnect: false, - reconnectAt: null, - forcedDisconnectDelay: 0, - lastConnectionAttempt: 0, - error: '', - }, - isConnected: true, - isStillReconnecting: false, - secondsUntilReconnect: () => 0, - tryReconnectNow: () => {}, - registerUserActivity: () => {}, - disconnect: () => {}, - })) - - return ( - <ConnectionContext.Provider value={value}> - {children} - </ConnectionContext.Provider> - ) - } - return ConnectionProvider -} - -const makeIdeReactProvider = (scope, socket) => { - const IdeReactProvider = ({ children }) => { - const [startedFreeTrial, setStartedFreeTrial] = useState(false) - - const [ideReactContextValue] = useState(() => ({ - projectId: PROJECT_ID, - eventEmitter: new IdeEventEmitter(), - startedFreeTrial, - setStartedFreeTrial, - reportError: () => {}, - projectJoined: true, - })) - - const [ideContextValue] = useState(() => { - const scopeStore = createReactScopeValueStore(PROJECT_ID) - for (const [key, value] of Object.entries(scope)) { - // TODO: path for nested entries - scopeStore.set(key, value) - } - scopeStore.set('editor.sharejs_doc', scope.editor.sharejs_doc) - scopeStore.set('ui.chatOpen', scope.ui.chatOpen) - const scopeEventEmitter = new ReactScopeEventEmitter( - new IdeEventEmitter() - ) - - return { - socket, - scopeStore, - scopeEventEmitter, - } - }) - - useEffect(() => { - window.overleaf = { - ...window.overleaf, - unstable: { - ...window.overleaf?.unstable, - store: ideContextValue.scopeStore, - }, - } - }, [ideContextValue.scopeStore]) - - return ( - <IdeReactContext.Provider value={ideReactContextValue}> - <IdeContext.Provider value={ideContextValue}> - {children} - </IdeContext.Provider> - </IdeReactContext.Provider> - ) - } - return IdeReactProvider -} diff --git a/services/web/test/frontend/helpers/editor-providers.tsx b/services/web/test/frontend/helpers/editor-providers.tsx new file mode 100644 index 0000000000..16df207b88 --- /dev/null +++ b/services/web/test/frontend/helpers/editor-providers.tsx @@ -0,0 +1,576 @@ +// Disable prop type checks for test harnesses +/* eslint-disable react/prop-types */ +import { merge } from 'lodash' +import { SocketIOMock } from '@/ide/connection/SocketIoShim' +import { IdeContext } from '@/shared/context/ide-context' +import React, { + useCallback, + useEffect, + useState, + useMemo, + type FC, + type PropsWithChildren, +} from 'react' +import { IdeReactContext } from '@/features/ide-react/context/ide-react-context' +import { IdeEventEmitter } from '@/features/ide-react/create-ide-event-emitter' +import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store' +import { ReactScopeEventEmitter } from '@/features/ide-react/scope-event-emitter/react-scope-event-emitter' +import { ConnectionContext } from '@/features/ide-react/context/connection-context' +import { + EditorOpenDocContext, + type EditorOpenDocContextState, +} from '@/features/ide-react/context/editor-open-doc-context' +import { ProjectContext } from '@/shared/context/project-context' +import { ReactContextRoot } from '@/features/ide-react/context/react-context-root' +import useEventListener from '@/shared/hooks/use-event-listener' +import useDetachLayout from '@/shared/hooks/use-detach-layout' +import useExposedState from '@/shared/hooks/use-exposed-state' +import { ProjectSnapshot } from '@/infrastructure/project-snapshot' +import { + EditorPropertiesContext, + EditorPropertiesContextValue, +} from '@/features/ide-react/context/editor-properties-context' +import { + type IdeLayout, + type IdeView, + LayoutContext, + type LayoutContextValue, +} from '@/shared/context/layout-context' +import type { Socket } from '@/features/ide-react/connection/types/socket' +import type { PermissionsLevel } from '@/features/ide-react/types/permissions' +import type { Folder } from '../../../types/folder' +import type { SocketDebuggingInfo } from '@/features/ide-react/connection/types/connection-state' +import type { DocumentContainer } from '@/features/ide-react/editor/document-container' +import { + ProjectMetadata, + ProjectUpdate, +} from '@/shared/context/types/project-metadata' +import { UserId } from '../../../types/user' +import { ProjectCompiler } from '../../../types/project-settings' + +// these constants can be imported in tests instead of +// using magic strings +export const PROJECT_ID = 'project123' +export const PROJECT_NAME = 'project-name' +export const USER_ID = '123abd' +export const USER_EMAIL = 'testuser@example.com' + +const defaultUserSettings = { + pdfViewer: 'pdfjs', + fontSize: 12, + fontFamily: 'monaco', + lineHeight: 'normal', + editorTheme: 'textmate', + overallTheme: '', + mode: 'default', + autoComplete: true, + autoPairDelimiters: true, + trackChanges: true, + syntaxValidation: false, + mathPreview: true, +} + +export type EditorProvidersProps = { + user?: { id: string; email: string } + projectId?: string + projectName?: string + projectOwner?: ProjectMetadata['owner'] + rootDocId?: string + imageName?: string + compiler?: ProjectCompiler + socket?: Socket + isRestrictedTokenMember?: boolean + scope?: Record<string, any> + features?: Record<string, boolean> + projectFeatures?: Record<string, boolean> + permissionsLevel?: PermissionsLevel + children?: React.ReactNode + rootFolder?: Folder[] + layoutContext?: Partial<LayoutContextValue> + userSettings?: Record<string, any> + providers?: Record<string, React.FC<React.PropsWithChildren<any>>> +} + +export const projectDefaults = { + _id: PROJECT_ID, + name: PROJECT_NAME, + owner: { + _id: '124abd' as UserId, + email: 'owner@example.com', + first_name: 'Test', + last_name: 'Owner', + privileges: 'owner', + signUpDate: new Date('2025-07-07').toISOString(), + }, + features: { + referencesSearch: true, + gitBridge: false, + }, + rootDocId: '_root_doc_id', + rootFolder: [ + { + _id: 'root-folder-id', + name: 'rootFolder', + docs: [ + { + _id: '_root_doc_id', + name: 'main.tex', + }, + ], + folders: [], + fileRefs: [], + }, + ], + imageName: 'texlive-full:2024.1', + compiler: 'pdflatex' as ProjectCompiler, + members: [], + invites: [], +} + +/** + * @typedef {import('@/shared/context/layout-context').LayoutContextValue} LayoutContextValue + * @type Partial<LayoutContextValue> + */ +const layoutContextDefault = { + view: 'editor', + openFile: null, + chatIsOpen: true, // false in the application, true in tests + reviewPanelOpen: false, + miniReviewPanelVisible: false, + leftMenuShown: false, + projectSearchIsOpen: false, + pdfLayout: 'sideBySide', + loadingStyleSheet: false, +} satisfies Partial<LayoutContextValue> + +export function EditorProviders({ + user = { id: USER_ID, email: USER_EMAIL }, + projectId = projectDefaults._id, + projectName = projectDefaults.name, + projectOwner = projectDefaults.owner, + rootDocId = projectDefaults.rootDocId, + imageName = projectDefaults.imageName, + compiler = projectDefaults.compiler, + socket = new SocketIOMock() as any as Socket, + isRestrictedTokenMember = false, + scope: defaultScope = {}, + features = { + referencesSearch: true, + gitBridge: false, + }, + projectFeatures = features, + permissionsLevel = 'owner', + children, + rootFolder = projectDefaults.rootFolder, + /** @type {Partial<LayoutContext>} */ + layoutContext = layoutContextDefault, + userSettings = {}, + providers = {}, +}: EditorProvidersProps) { + window.metaAttributesCache.set( + 'ol-gitBridgePublicBaseUrl', + 'https://git.overleaf.test' + ) + window.metaAttributesCache.set( + 'ol-isRestrictedTokenMember', + isRestrictedTokenMember + ) + window.metaAttributesCache.set( + 'ol-userSettings', + merge({}, defaultUserSettings, userSettings) + ) + + window.metaAttributesCache.set('ol-capabilities', ['chat', 'dropbox']) + + const scope = merge( + { + user, + editor: { + sharejs_doc: { + doc_id: 'test-doc', + getSnapshot: () => 'some doc content', + hasBufferedOps: () => false, + on: () => {}, + off: () => {}, + leaveAndCleanUpPromise: async () => {}, + } as any as DocumentContainer, + openDocName: null, + currentDocumentId: null, + wantTrackChanges: false, + }, + permissionsLevel, + }, + defaultScope + ) + + const project = { + _id: projectId, + name: projectName, + owner: projectOwner, + features: projectFeatures, + rootDocId, + rootFolder, + imageName, + compiler, + members: [], + invites: [], + trackChangesState: false, + spellCheckLanguage: 'en', + } + + // Add details for useUserContext + window.metaAttributesCache.set('ol-user', { ...user, features }) + window.metaAttributesCache.set('ol-project_id', projectId) + + return ( + <ReactContextRoot + providers={{ + ConnectionProvider: makeConnectionProvider(socket), + IdeReactProvider: makeIdeReactProvider(scope, socket), + EditorOpenDocProvider: makeEditorOpenDocProvider({ + currentDocumentId: scope.editor.currentDocumentId, + openDocName: scope.editor.openDocName, + currentDocument: scope.editor.sharejs_doc, + }), + EditorPropertiesProvider: makeEditorPropertiesProvider({ + wantTrackChanges: scope.editor.wantTrackChanges, + }), + LayoutProvider: makeLayoutProvider(layoutContext), + ProjectProvider: makeProjectProvider(project), + ...providers, + }} + > + {children} + </ReactContextRoot> + ) +} + +const makeConnectionProvider = (socket: Socket) => { + const ConnectionProvider: FC<PropsWithChildren> = ({ children }) => { + const [value] = useState(() => ({ + socket, + connectionState: { + readyState: WebSocket.OPEN, + forceDisconnected: false, + inactiveDisconnect: false, + reconnectAt: null, + forcedDisconnectDelay: 0, + lastConnectionAttempt: 0, + error: '' as const, + }, + isConnected: true, + isStillReconnecting: false, + secondsUntilReconnect: () => 0, + tryReconnectNow: () => {}, + registerUserActivity: () => {}, + disconnect: () => {}, + closeConnection: () => {}, + getSocketDebuggingInfo: () => ({}) as SocketDebuggingInfo, + })) + + return ( + <ConnectionContext.Provider value={value}> + {children} + </ConnectionContext.Provider> + ) + } + return ConnectionProvider +} + +const makeIdeReactProvider = ( + scope: Record<string, unknown>, + socket: Socket +) => { + const IdeReactProvider: FC<PropsWithChildren> = ({ children }) => { + const [startedFreeTrial, setStartedFreeTrial] = useState(false) + + const [ideReactContextValue] = useState(() => ({ + projectId: PROJECT_ID, + eventEmitter: new IdeEventEmitter(), + startedFreeTrial, + setStartedFreeTrial, + reportError: () => {}, + projectJoined: true, + permissionsLevel: scope.permissionsLevel as PermissionsLevel, + setPermissionsLevel: () => {}, + setOutOfSync: () => {}, + })) + + const [ideContextValue] = useState(() => { + const scopeEventEmitter = new ReactScopeEventEmitter( + new IdeEventEmitter() + ) + const unstableStore = new ReactScopeValueStore() + + return { + socket, + scopeEventEmitter, + unstableStore, + } + }) + + useEffect(() => { + window.overleaf = { + ...window.overleaf, + unstable: { + ...window.overleaf?.unstable, + store: ideContextValue.unstableStore, + }, + } + }, [ideContextValue.unstableStore]) + + return ( + <IdeReactContext.Provider value={ideReactContextValue}> + <IdeContext.Provider value={ideContextValue}> + {children} + </IdeContext.Provider> + </IdeReactContext.Provider> + ) + } + return IdeReactProvider +} + +export function makeEditorOpenDocProvider( + initialValues: EditorOpenDocContextState +) { + const { + currentDocumentId: initialCurrentDocumentId, + openDocName: initialOpenDocName, + currentDocument: initialCurrentDocument, + } = initialValues + const EditorOpenDocProvider: FC<PropsWithChildren> = ({ children }) => { + const [currentDocumentId, setCurrentDocumentId] = useExposedState( + initialCurrentDocumentId, + 'editor.open_doc_id' + ) + const [openDocName, setOpenDocName] = useExposedState( + initialOpenDocName, + 'editor.open_doc_name' + ) + const [currentDocument, setCurrentDocument] = useState( + initialCurrentDocument + ) + + const value = { + currentDocumentId, + setCurrentDocumentId, + openDocName, + setOpenDocName, + currentDocument, + setCurrentDocument, + } + + return ( + <EditorOpenDocContext.Provider value={value}> + {children} + </EditorOpenDocContext.Provider> + ) + } + + return EditorOpenDocProvider +} + +const makeLayoutProvider = ( + layoutContextOverrides?: Partial<LayoutContextValue> +) => { + const layout = { + ...layoutContextDefault, + ...layoutContextOverrides, + } + const LayoutProvider: FC<PropsWithChildren> = ({ children }) => { + const [view, setView] = useState<IdeView | null>(layout.view) + const [openFile, setOpenFile] = useState(layout.openFile) + const [chatIsOpen, setChatIsOpen] = useState(layout.chatIsOpen) + const [reviewPanelOpen, setReviewPanelOpen] = useState( + layout.reviewPanelOpen + ) + const [miniReviewPanelVisible, setMiniReviewPanelVisible] = useState( + layout.miniReviewPanelVisible + ) + const [leftMenuShown, setLeftMenuShown] = useState(layout.leftMenuShown) + const [projectSearchIsOpen, setProjectSearchIsOpen] = useState( + layout.projectSearchIsOpen + ) + const [pdfLayout, setPdfLayout] = useState(layout.pdfLayout) + const [loadingStyleSheet, setLoadingStyleSheet] = useState( + layout.loadingStyleSheet + ) + + useEventListener( + 'ui.toggle-review-panel', + useCallback(() => { + setReviewPanelOpen(open => !open) + }, [setReviewPanelOpen]) + ) + const changeLayout = useCallback( + (newLayout: IdeLayout, newView: IdeView = 'editor') => { + setPdfLayout(newLayout) + setView(newLayout === 'sideBySide' ? 'editor' : newView) + }, + [setPdfLayout, setView] + ) + + const restoreView = useCallback(() => { + setView('editor') + }, []) + + const { + reattach, + detach, + isLinked: detachIsLinked, + role: detachRole, + } = useDetachLayout() + const pdfPreviewOpen = + pdfLayout === 'sideBySide' || view === 'pdf' || detachRole === 'detacher' + const value = useMemo( + () => ({ + reattach, + detach, + detachIsLinked, + detachRole, + changeLayout, + chatIsOpen, + leftMenuShown, + openFile, + pdfLayout, + pdfPreviewOpen, + projectSearchIsOpen, + setProjectSearchIsOpen, + reviewPanelOpen, + miniReviewPanelVisible, + loadingStyleSheet, + setChatIsOpen, + setLeftMenuShown, + setOpenFile, + setPdfLayout, + setReviewPanelOpen, + setMiniReviewPanelVisible, + setLoadingStyleSheet, + setView, + view, + restoreView, + }), + [ + reattach, + detach, + detachIsLinked, + detachRole, + changeLayout, + chatIsOpen, + leftMenuShown, + openFile, + pdfLayout, + pdfPreviewOpen, + projectSearchIsOpen, + setProjectSearchIsOpen, + reviewPanelOpen, + miniReviewPanelVisible, + loadingStyleSheet, + setChatIsOpen, + setLeftMenuShown, + setOpenFile, + setPdfLayout, + setReviewPanelOpen, + setMiniReviewPanelVisible, + setLoadingStyleSheet, + setView, + view, + restoreView, + ] + ) + + return ( + <LayoutContext.Provider value={value}>{children}</LayoutContext.Provider> + ) + } + return LayoutProvider +} + +export function makeEditorPropertiesProvider( + initialValues: Partial< + Pick< + EditorPropertiesContextValue, + 'showVisual' | 'showSymbolPalette' | 'wantTrackChanges' + > + > +) { + const EditorPropertiesProvider: FC<PropsWithChildren> = ({ children }) => { + const { + showVisual: initialShowVisual, + showSymbolPalette: initialShowSymbolPalette, + wantTrackChanges: initialWantTrackChanges, + } = initialValues + + const [showVisual, setShowVisual] = useState(initialShowVisual || false) + const [showSymbolPalette, setShowSymbolPalette] = useState( + initialShowSymbolPalette || false + ) + + function toggleSymbolPalette() { + setShowSymbolPalette(show => !show) + } + + const [opening, setOpening] = useState(true) + const [trackChanges, setTrackChanges] = useState(false) + const [wantTrackChanges, setWantTrackChanges] = useState( + initialWantTrackChanges || false + ) + const [errorState, setErrorState] = useState(false) + + const value = { + showVisual, + setShowVisual, + showSymbolPalette, + setShowSymbolPalette, + toggleSymbolPalette, + opening, + setOpening, + trackChanges, + setTrackChanges, + wantTrackChanges, + setWantTrackChanges, + errorState, + setErrorState, + } + + return ( + <EditorPropertiesContext.Provider value={value}> + {children} + </EditorPropertiesContext.Provider> + ) + } + + return EditorPropertiesProvider +} + +export function makeProjectProvider(initialProject: ProjectMetadata) { + const ProjectProvider: FC<PropsWithChildren> = ({ children }) => { + const [project, setProject] = useState(initialProject) + + const updateProject = useCallback((projectUpdateData: ProjectUpdate) => { + setProject(projectData => + Object.assign({}, projectData, projectUpdateData) + ) + }, []) + + const value = { + projectId: project._id, + project, + joinProject: () => {}, + updateProject, + joinedOnce: true, + projectSnapshot: new ProjectSnapshot(project._id), + tags: [], + features: project.features, + name: project.name, + } + + return ( + <ProjectContext.Provider value={value}> + {children} + </ProjectContext.Provider> + ) + } + + return ProjectProvider +} diff --git a/services/web/test/frontend/helpers/render-with-context.jsx b/services/web/test/frontend/helpers/render-with-context.jsx deleted file mode 100644 index 31ee64d5be..0000000000 --- a/services/web/test/frontend/helpers/render-with-context.jsx +++ /dev/null @@ -1,20 +0,0 @@ -// Disable prop type checks for test harnesses -/* eslint-disable react/prop-types */ - -import { render } from '@testing-library/react' -import { EditorProviders } from './editor-providers' - -export function renderWithEditorContext( - component, - contextProps, - renderOptions = {} -) { - const EditorProvidersWrapper = ({ children }) => ( - <EditorProviders {...contextProps}>{children}</EditorProviders> - ) - - return render(component, { - wrapper: EditorProvidersWrapper, - ...renderOptions, - }) -} diff --git a/services/web/test/frontend/helpers/render-with-context.tsx b/services/web/test/frontend/helpers/render-with-context.tsx new file mode 100644 index 0000000000..cf7aa0b575 --- /dev/null +++ b/services/web/test/frontend/helpers/render-with-context.tsx @@ -0,0 +1,20 @@ +import React from 'react' +import { render, type RenderOptions } from '@testing-library/react' +import { EditorProviders, type EditorProvidersProps } from './editor-providers' + +export function renderWithEditorContext( + component: React.ReactElement, + contextProps: EditorProvidersProps = {}, + renderOptions: RenderOptions = {} +) { + const EditorProvidersWrapper = ({ + children, + }: { + children: React.ReactNode + }) => <EditorProviders {...contextProps}>{children}</EditorProviders> + + return render(component, { + wrapper: EditorProvidersWrapper, + ...renderOptions, + }) +} diff --git a/services/web/test/frontend/shared/components/icon.test.jsx b/services/web/test/frontend/shared/components/icon.test.jsx deleted file mode 100644 index b10bf0c7c8..0000000000 --- a/services/web/test/frontend/shared/components/icon.test.jsx +++ /dev/null @@ -1,51 +0,0 @@ -import { expect } from 'chai' -import { screen, render } from '@testing-library/react' - -import Icon from '../../../../frontend/js/shared/components/icon' - -describe('<Icon />', function () { - it('renders basic fa classes', function () { - const { container } = render(<Icon type="angle-down" />) - const element = container.querySelector('i.fa.fa-angle-down') - expect(element).to.exist - }) - - it('renders with aria-hidden', function () { - const { container } = render(<Icon type="angle-down" />) - const element = container.querySelector('i[aria-hidden="true"]') - expect(element).to.exist - }) - - it('renders accessible label', function () { - render(<Icon type="angle-down" accessibilityLabel="Accessible Foo" />) - screen.getByText('Accessible Foo') - }) - - it('renders with spin', function () { - const { container } = render(<Icon type="angle-down" spin />) - const element = container.querySelector('i.fa.fa-angle-down.fa-spin') - expect(element).to.exist - }) - - it('renders with fw', function () { - const { container } = render(<Icon type="angle-down" fw />) - const element = container.querySelector('i.fa.fa-angle-down.fa-fw') - expect(element).to.exist - }) - - it('renders with modifier', function () { - const { container } = render(<Icon type="angle-down" modifier="2x" />) - const element = container.querySelector('i.fa.fa-angle-down.fa-2x') - expect(element).to.exist - }) - - it('renders with custom clases', function () { - const { container } = render( - <Icon type="angle-down" className="custom-icon-class" /> - ) - const element = container.querySelector( - 'i.fa.fa-angle-down.custom-icon-class' - ) - expect(element).to.exist - }) -}) diff --git a/services/web/test/frontend/shared/components/processing.test.jsx b/services/web/test/frontend/shared/components/processing.test.jsx deleted file mode 100644 index a053193721..0000000000 --- a/services/web/test/frontend/shared/components/processing.test.jsx +++ /dev/null @@ -1,16 +0,0 @@ -import { expect } from 'chai' -import { render } from '@testing-library/react' -import Processing from '../../../../frontend/js/shared/components/processing' - -describe('<Processing />', function () { - it('renders processing UI when isProcessing is true', function () { - const { container } = render(<Processing isProcessing />) - const element = container.querySelector('i.fa.fa-refresh') - expect(element).to.exist - }) - it('does not render processing UI when isProcessing is false', function () { - const { container } = render(<Processing isProcessing={false} />) - const element = container.querySelector('i.fa.fa-refresh') - expect(element).to.not.exist - }) -}) diff --git a/services/web/test/frontend/shared/hooks/use-persisted-state.test.tsx b/services/web/test/frontend/shared/hooks/use-persisted-state.test.tsx index 5b670698e3..9664148ce7 100644 --- a/services/web/test/frontend/shared/hooks/use-persisted-state.test.tsx +++ b/services/web/test/frontend/shared/hooks/use-persisted-state.test.tsx @@ -22,7 +22,7 @@ describe('usePersistedState', function () { expect(window.Storage.prototype.setItem).to.have.callCount(1) const Test = () => { - const [value] = usePersistedState(key) + const [value] = usePersistedState<string>(key) return <div>{value}</div> } @@ -139,6 +139,35 @@ describe('usePersistedState', function () { expect(localStorage.getItem(key)).to.equal('foobar') }) + it('converts persisted value (string to boolean)', function () { + const key = 'test:convert' + localStorage.setItem(key, 'yep') + + const Test = () => { + const [value, setValue] = usePersistedState(key, true, { + converter: { + toPersisted(value) { + return value ? 'yep' : 'nope' + }, + fromPersisted(persistedValue) { + return persistedValue === 'yep' + }, + }, + }) + + useEffect(() => { + setValue(false) + }, [setValue]) + + return <div>{String(value)}</div> + } + + render(<Test />) + + screen.getByText('false') + expect(localStorage.getItem(key)).to.equal('nope') + }) + it('handles syncing values via storage event', async function () { const key = 'test:sync' localStorage.setItem(key, 'foo') @@ -149,7 +178,7 @@ describe('usePersistedState', function () { window.addEventListener('storage', storageEventListener) const Test = () => { - const [value, setValue] = usePersistedState(key, 'bar', true) + const [value, setValue] = usePersistedState(key, 'bar', { listen: true }) useEffect(() => { setValue('baz') diff --git a/services/web/test/smoke/src/steps/100_loadProjectDashboard.js b/services/web/test/smoke/src/steps/100_loadProjectDashboard.js index 2a60f8d4b3..aa471e0672 100644 --- a/services/web/test/smoke/src/steps/100_loadProjectDashboard.js +++ b/services/web/test/smoke/src/steps/100_loadProjectDashboard.js @@ -1,4 +1,5 @@ -const TITLE_REGEX = /<title>Your projects - .*, Online LaTeX Editor<\/title>/ +const TITLE_REGEX = + /<title[^>]*>Your projects - .*, Online LaTeX Editor<\/title>/ async function run({ request, assertHasStatusCode }) { const response = await request('/project') diff --git a/services/web/test/unit/src/Analytics/AnalyticsController.test.mjs b/services/web/test/unit/src/Analytics/AnalyticsController.test.mjs index 4019f2bce9..f0d047ef28 100644 --- a/services/web/test/unit/src/Analytics/AnalyticsController.test.mjs +++ b/services/web/test/unit/src/Analytics/AnalyticsController.test.mjs @@ -68,8 +68,8 @@ describe('AnalyticsController', function () { .resolves({ country_code: 'XY' }) }) - it('delegates to the AnalyticsManager', function (ctx) { - return new Promise(resolve => { + it('delegates to the AnalyticsManager', async function (ctx) { + await new Promise(resolve => { ctx.SessionManager.getLoggedInUserId.returns('1234') ctx.res.callback = () => { sinon.assert.calledWith( @@ -105,8 +105,8 @@ describe('AnalyticsController', function () { delete ctx.expectedData._csrf }) - it('should use the session', function (ctx) { - return new Promise(resolve => { + it('should use the session', async function (ctx) { + await new Promise(resolve => { ctx.controller.recordEvent(ctx.req, ctx.res) sinon.assert.calledWith( ctx.AnalyticsManager.recordEventForSession, @@ -118,8 +118,8 @@ describe('AnalyticsController', function () { }) }) - it('should remove the CSRF token before sending to the manager', function (ctx) { - return new Promise(resolve => { + it('should remove the CSRF token before sending to the manager', async function (ctx) { + await new Promise(resolve => { ctx.controller.recordEvent(ctx.req, ctx.res) sinon.assert.calledWith( ctx.AnalyticsManager.recordEventForSession, diff --git a/services/web/test/unit/src/BetaProgram/BetaProgramController.test.mjs b/services/web/test/unit/src/BetaProgram/BetaProgramController.test.mjs index 23dd4dc1c8..b1fc8cf2fa 100644 --- a/services/web/test/unit/src/BetaProgram/BetaProgramController.test.mjs +++ b/services/web/test/unit/src/BetaProgram/BetaProgramController.test.mjs @@ -79,13 +79,17 @@ describe('BetaProgramController', function () { }) describe('optIn', function () { - it("should redirect to '/beta/participate'", function (ctx) { - return new Promise(resolve => { + it("should redirect to '/beta/participate'", async function (ctx) { + await new Promise((resolve, reject) => { ctx.res.callback = () => { ctx.res.redirectedTo.should.equal('/beta/participate') resolve() } - ctx.BetaProgramController.optIn(ctx.req, ctx.res, resolve) + ctx.BetaProgramController.optIn( + ctx.req, + ctx.res, + ctx.rejectOnError(reject) + ) }) }) @@ -99,15 +103,15 @@ describe('BetaProgramController', function () { ctx.BetaProgramHandler.promises.optIn.callCount.should.equal(1) }) - it('should invoke the session maintenance', function (ctx) { - return new Promise(resolve => { + it('should invoke the session maintenance', async function (ctx) { + await new Promise(resolve => { ctx.res.callback = () => { ctx.SplitTestSessionHandler.promises.sessionMaintenance.should.have.been.calledWith( ctx.req ) resolve() } - ctx.BetaProgramController.optIn(ctx.req, ctx.res, resolve) + ctx.BetaProgramController.optIn(ctx.req, ctx.res) }) }) @@ -121,8 +125,8 @@ describe('BetaProgramController', function () { ctx.res.redirect.callCount.should.equal(0) }) - it('should produce an error', function (ctx) { - return new Promise(resolve => { + it('should produce an error', async function (ctx) { + await new Promise(resolve => { ctx.BetaProgramController.optIn(ctx.req, ctx.res, err => { expect(err).to.be.instanceof(Error) resolve() @@ -133,38 +137,50 @@ describe('BetaProgramController', function () { }) describe('optOut', function () { - it("should redirect to '/beta/participate'", function (ctx) { - return new Promise(resolve => { + it("should redirect to '/beta/participate'", async function (ctx) { + await new Promise((resolve, reject) => { ctx.res.callback = () => { expect(ctx.res.redirectedTo).to.equal('/beta/participate') resolve() } - ctx.BetaProgramController.optOut(ctx.req, ctx.res, resolve) + ctx.BetaProgramController.optOut( + ctx.req, + ctx.res, + ctx.rejectOnError(reject) + ) }) }) - it('should not call next with an error', function (ctx) { - return new Promise(resolve => { + it('should not call next with an error', async function (ctx) { + await new Promise((resolve, reject) => { ctx.res.callback = () => { ctx.next.callCount.should.equal(0) resolve() } - ctx.BetaProgramController.optOut(ctx.req, ctx.res, resolve) + ctx.BetaProgramController.optOut( + ctx.req, + ctx.res, + ctx.rejectOnError(reject) + ) }) }) - it('should call BetaProgramHandler.optOut', function (ctx) { - return new Promise(resolve => { + it('should call BetaProgramHandler.optOut', async function (ctx) { + await new Promise((resolve, reject) => { ctx.res.callback = () => { ctx.BetaProgramHandler.promises.optOut.callCount.should.equal(1) resolve() } - ctx.BetaProgramController.optOut(ctx.req, ctx.res, resolve) + ctx.BetaProgramController.optOut( + ctx.req, + ctx.res, + ctx.rejectOnError(reject) + ) }) }) - it('should invoke the session maintenance', function (ctx) { - return new Promise(resolve => { + it('should invoke the session maintenance', async function (ctx) { + await new Promise((resolve, reject) => { ctx.res.callback = () => { ctx.SplitTestSessionHandler.promises.sessionMaintenance.should.have.been.calledWith( ctx.req, @@ -172,7 +188,11 @@ describe('BetaProgramController', function () { ) resolve() } - ctx.BetaProgramController.optOut(ctx.req, ctx.res, resolve) + ctx.BetaProgramController.optOut( + ctx.req, + ctx.res, + ctx.rejectOnError(reject) + ) }) }) @@ -181,8 +201,8 @@ describe('BetaProgramController', function () { ctx.BetaProgramHandler.promises.optOut.throws(new Error('woops')) }) - it("should not redirect to '/beta/participate'", function (ctx) { - return new Promise(resolve => { + it("should not redirect to '/beta/participate'", async function (ctx) { + await new Promise(resolve => { ctx.BetaProgramController.optOut(ctx.req, ctx.res, error => { expect(error).to.exist expect(ctx.res.redirected).to.equal(false) @@ -191,8 +211,8 @@ describe('BetaProgramController', function () { }) }) - it('should produce an error', function (ctx) { - return new Promise(resolve => { + it('should produce an error', async function (ctx) { + await new Promise(resolve => { ctx.BetaProgramController.optOut(ctx.req, ctx.res, error => { expect(error).to.exist resolve() @@ -207,13 +227,17 @@ describe('BetaProgramController', function () { ctx.UserGetter.promises.getUser.resolves(ctx.user) }) - it('should render the opt-in page', function (ctx) { - return new Promise(resolve => { + it('should render the opt-in page', async function (ctx) { + await new Promise((resolve, reject) => { ctx.res.callback = () => { expect(ctx.res.renderedTemplate).to.equal('beta_program/opt_in') resolve() } - ctx.BetaProgramController.optInPage(ctx.req, ctx.res, resolve) + ctx.BetaProgramController.optInPage( + ctx.req, + ctx.res, + ctx.rejectOnError(reject) + ) }) }) @@ -227,8 +251,8 @@ describe('BetaProgramController', function () { ctx.res.render.callCount.should.equal(0) }) - it('should produce an error', function (ctx) { - return new Promise(resolve => { + it('should produce an error', async function (ctx) { + await new Promise(resolve => { ctx.BetaProgramController.optInPage(ctx.req, ctx.res, error => { expect(error).to.exist expect(error).to.be.instanceof(Error) diff --git a/services/web/test/unit/src/BetaProgram/BetaProgramHandler.test.mjs b/services/web/test/unit/src/BetaProgram/BetaProgramHandler.test.mjs index 4034835666..2e6ffb4568 100644 --- a/services/web/test/unit/src/BetaProgram/BetaProgramHandler.test.mjs +++ b/services/web/test/unit/src/BetaProgram/BetaProgramHandler.test.mjs @@ -56,8 +56,8 @@ describe('BetaProgramHandler', function () { } }) - it('should call userUpdater', function (ctx) { - return new Promise(resolve => { + it('should call userUpdater', async function (ctx) { + await new Promise(resolve => { ctx.call(err => { expect(err).to.not.exist ctx.UserUpdater.promises.updateUser.callCount.should.equal(1) @@ -66,8 +66,8 @@ describe('BetaProgramHandler', function () { }) }) - it('should set beta-program user property to true', function (ctx) { - return new Promise(resolve => { + it('should set beta-program user property to true', async function (ctx) { + await new Promise(resolve => { ctx.call(err => { expect(err).to.not.exist sinon.assert.calledWith( @@ -81,8 +81,8 @@ describe('BetaProgramHandler', function () { }) }) - it('should not produce an error', function (ctx) { - return new Promise(resolve => { + it('should not produce an error', async function (ctx) { + await new Promise(resolve => { ctx.call(err => { expect(err).to.not.exist resolve() @@ -95,8 +95,8 @@ describe('BetaProgramHandler', function () { ctx.UserUpdater.promises.updateUser.rejects() }) - it('should produce an error', function (ctx) { - return new Promise(resolve => { + it('should produce an error', async function (ctx) { + await new Promise(resolve => { ctx.call(err => { expect(err).to.exist expect(err).to.be.instanceof(Error) @@ -115,8 +115,8 @@ describe('BetaProgramHandler', function () { } }) - it('should call userUpdater', function (ctx) { - return new Promise(resolve => { + it('should call userUpdater', async function (ctx) { + await new Promise(resolve => { ctx.call(err => { expect(err).to.not.exist ctx.UserUpdater.promises.updateUser.callCount.should.equal(1) @@ -125,8 +125,8 @@ describe('BetaProgramHandler', function () { }) }) - it('should set beta-program user property to false', function (ctx) { - return new Promise(resolve => { + it('should set beta-program user property to false', async function (ctx) { + await new Promise(resolve => { ctx.call(err => { expect(err).to.not.exist sinon.assert.calledWith( @@ -140,8 +140,8 @@ describe('BetaProgramHandler', function () { }) }) - it('should not produce an error', function (ctx) { - return new Promise(resolve => { + it('should not produce an error', async function (ctx) { + await new Promise(resolve => { ctx.call(err => { expect(err).to.not.exist resolve() @@ -154,8 +154,8 @@ describe('BetaProgramHandler', function () { ctx.UserUpdater.promises.updateUser.rejects() }) - it('should produce an error', function (ctx) { - return new Promise(resolve => { + it('should produce an error', async function (ctx) { + await new Promise(resolve => { ctx.call(err => { expect(err).to.exist expect(err).to.be.instanceof(Error) diff --git a/services/web/test/unit/src/BrandVariations/BrandVariationsHandlerTests.js b/services/web/test/unit/src/BrandVariations/BrandVariationsHandlerTests.js index 47d96406f9..75adfd7e9b 100644 --- a/services/web/test/unit/src/BrandVariations/BrandVariationsHandlerTests.js +++ b/services/web/test/unit/src/BrandVariations/BrandVariationsHandlerTests.js @@ -118,5 +118,21 @@ describe('BrandVariationsHandler', function () { '<br /><strong style="color:#B39500">AGU Journal</strong>hello' ) }) + + it("should sanitize and remove breaks in 'submit_button_html_no_br'", async function () { + this.mockedBrandVariationDetails.submit_button_html = + 'Submit to<br class="break"/><strong style="color:#B39500">AGU Journal</strong><iframe>hello</iframe>' + this.V1Api.request.callsArgWith( + 1, + null, + { statusCode: 200 }, + this.mockedBrandVariationDetails + ) + const brandVariationDetails = + await this.BrandVariationsHandler.promises.getBrandVariationById('12') + expect(brandVariationDetails.submit_button_html_no_br).to.equal( + 'Submit to <strong style="color:#B39500">AGU Journal</strong>hello' + ) + }) }) }) diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsController.test.mjs b/services/web/test/unit/src/Collaborators/CollaboratorsController.test.mjs index 1d8345a195..85beb949b7 100644 --- a/services/web/test/unit/src/Collaborators/CollaboratorsController.test.mjs +++ b/services/web/test/unit/src/Collaborators/CollaboratorsController.test.mjs @@ -169,8 +169,8 @@ describe('CollaboratorsController', function () { }) describe('removeUserFromProject', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.req.params = { Project_id: ctx.projectId, user_id: ctx.user._id, @@ -219,8 +219,8 @@ describe('CollaboratorsController', function () { }) describe('removeSelfFromProject', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.req.params = { Project_id: ctx.projectId } ctx.res.sendStatus = sinon.spy(() => { resolve() @@ -264,8 +264,8 @@ describe('CollaboratorsController', function () { }) describe('getAllMembers', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.req.params = { Project_id: ctx.projectId } ctx.res.json = sinon.spy(() => { resolve() @@ -294,8 +294,8 @@ describe('CollaboratorsController', function () { }) describe('when CollaboratorsGetter.getAllInvitedMembers produces an error', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.res.json = sinon.stub() ctx.next = sinon.spy(() => { resolve() @@ -329,8 +329,8 @@ describe('CollaboratorsController', function () { ctx.req.body = { privilegeLevel: 'readOnly' } }) - it('should set the collaborator privilege level', function (ctx) { - return new Promise(resolve => { + it('should set the collaborator privilege level', async function (ctx) { + await new Promise(resolve => { ctx.res.sendStatus = status => { expect(status).to.equal(204) expect( @@ -342,8 +342,8 @@ describe('CollaboratorsController', function () { }) }) - it('should return a 404 when the project or collaborator is not found', function (ctx) { - return new Promise(resolve => { + it('should return a 404 when the project or collaborator is not found', async function (ctx) { + await new Promise(resolve => { ctx.HttpErrorHandler.notFound = sinon.spy((req, res) => { expect(req).to.equal(ctx.req) expect(res).to.equal(ctx.res) @@ -357,8 +357,8 @@ describe('CollaboratorsController', function () { }) }) - it('should pass the error to the next handler when setting the privilege level fails', function (ctx) { - return new Promise(resolve => { + it('should pass the error to the next handler when setting the privilege level fails', async function (ctx) { + await new Promise(resolve => { ctx.next = sinon.spy(err => { expect(err).instanceOf(Error) resolve() @@ -381,8 +381,8 @@ describe('CollaboratorsController', function () { }) describe('when owner can add new edit collaborators', function () { - it('should set privilege level after checking collaborators can be added', function (ctx) { - return new Promise(resolve => { + it('should set privilege level after checking collaborators can be added', async function (ctx) { + await new Promise(resolve => { ctx.res.sendStatus = status => { expect(status).to.equal(204) expect( @@ -407,8 +407,8 @@ describe('CollaboratorsController', function () { ) }) - it('should return a 403 if trying to set a new edit collaborator', function (ctx) { - return new Promise(resolve => { + it('should return a 403 if trying to set a new edit collaborator', async function (ctx) { + await new Promise(resolve => { ctx.HttpErrorHandler.forbidden = sinon.spy((req, res) => { expect(req).to.equal(ctx.req) expect(res).to.equal(ctx.res) @@ -443,8 +443,8 @@ describe('CollaboratorsController', function () { ) }) - it('should always allow setting a collaborator to viewer even if user cant add edit collaborators', function (ctx) { - return new Promise(resolve => { + it('should always allow setting a collaborator to viewer even if user cant add edit collaborators', async function (ctx) { + await new Promise(resolve => { ctx.res.sendStatus = status => { expect(status).to.equal(204) expect(ctx.LimitationsManager.promises.canAddXEditCollaborators) @@ -466,8 +466,8 @@ describe('CollaboratorsController', function () { ctx.req.body = { user_id: ctx.user._id.toString() } }) - it('returns 204 on success', function (ctx) { - return new Promise(resolve => { + it('returns 204 on success', async function (ctx) { + await new Promise(resolve => { ctx.res.sendStatus = status => { expect(status).to.equal(204) resolve() @@ -476,8 +476,8 @@ describe('CollaboratorsController', function () { }) }) - it('returns 404 if the project does not exist', function (ctx) { - return new Promise(resolve => { + it('returns 404 if the project does not exist', async function (ctx) { + await new Promise(resolve => { ctx.HttpErrorHandler.notFound = sinon.spy((req, res, message) => { expect(req).to.equal(ctx.req) expect(res).to.equal(ctx.res) @@ -491,8 +491,8 @@ describe('CollaboratorsController', function () { }) }) - it('returns 404 if the user does not exist', function (ctx) { - return new Promise(resolve => { + it('returns 404 if the user does not exist', async function (ctx) { + await new Promise(resolve => { ctx.HttpErrorHandler.notFound = sinon.spy((req, res, message) => { expect(req).to.equal(ctx.req) expect(res).to.equal(ctx.res) @@ -506,8 +506,8 @@ describe('CollaboratorsController', function () { }) }) - it('invokes HTTP forbidden error handler if the user is not a collaborator', function (ctx) { - return new Promise(resolve => { + it('invokes HTTP forbidden error handler if the user is not a collaborator', async function (ctx) { + await new Promise(resolve => { ctx.HttpErrorHandler.forbidden = sinon.spy(() => resolve()) ctx.OwnershipTransferHandler.promises.transferOwnership.rejects( new Errors.UserNotCollaboratorError() diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsHandlerTests.js b/services/web/test/unit/src/Collaborators/CollaboratorsHandlerTests.js index 73fb699772..c97e4f2fe8 100644 --- a/services/web/test/unit/src/Collaborators/CollaboratorsHandlerTests.js +++ b/services/web/test/unit/src/Collaborators/CollaboratorsHandlerTests.js @@ -24,16 +24,6 @@ describe('CollaboratorsHandler', function () { name: 'Foo', } - this.archivedProject = { - _id: new ObjectId(), - archived: [new ObjectId(this.userId)], - } - - this.oldArchivedProject = { - _id: new ObjectId(), - archived: true, - } - this.UserGetter = { promises: { getUser: sinon.stub().resolves(null), @@ -59,9 +49,6 @@ describe('CollaboratorsHandler', function () { }, } - this.ProjectHelper = { - calculateArchivedArray: sinon.stub(), - } this.CollaboratorsGetter = { promises: { dangerouslyGetAllProjectsUserIsMemberOf: sinon.stub(), @@ -77,51 +64,10 @@ describe('CollaboratorsHandler', function () { '../ThirdPartyDataStore/TpdsProjectFlusher': this.TpdsProjectFlusher, '../ThirdPartyDataStore/TpdsUpdateSender': this.TpdsUpdateSender, '../Project/ProjectGetter': this.ProjectGetter, - '../Project/ProjectHelper': this.ProjectHelper, '../Editor/EditorRealTimeController': this.EditorRealTimeController, './CollaboratorsGetter': this.CollaboratorsGetter, }, }) - - // Helper function to set up mock expectations for null reference cleanup - this.expectNullReferenceCleanup = projectId => { - this.ProjectMock.expects('updateOne') - .withArgs( - { - _id: projectId, - pendingReviewer_refs: { $type: 'null' }, - }, - { - $set: { pendingReviewer_refs: [] }, - } - ) - .chain('exec') - .resolves() - this.ProjectMock.expects('updateOne') - .withArgs( - { - _id: projectId, - readOnly_refs: { $type: 'null' }, - }, - { - $set: { readOnly_refs: [] }, - } - ) - .chain('exec') - .resolves() - this.ProjectMock.expects('updateOne') - .withArgs( - { - _id: projectId, - reviewer_refs: { $type: 'null' }, - }, - { - $set: { reviewer_refs: [] }, - } - ) - .chain('exec') - .resolves() - } }) afterEach(function () { @@ -130,17 +76,7 @@ describe('CollaboratorsHandler', function () { describe('removeUserFromProject', function () { describe('a non-archived project', function () { - beforeEach(function () { - this.ProjectMock.expects('findOne') - .withArgs({ - _id: this.project._id, - }) - .chain('exec') - .resolves(this.project) - }) - it('should remove the user from mongo', async function () { - this.expectNullReferenceCleanup(this.project._id) this.ProjectMock.expects('updateOne') .withArgs( { @@ -168,89 +104,6 @@ describe('CollaboratorsHandler', function () { ) }) }) - - describe('an archived project, archived with a boolean value', function () { - beforeEach(function () { - const archived = [new ObjectId(this.userId)] - this.ProjectHelper.calculateArchivedArray.returns(archived) - - this.ProjectMock.expects('findOne') - .withArgs({ - _id: this.oldArchivedProject._id, - }) - .chain('exec') - .resolves(this.oldArchivedProject) - }) - - it('should remove the user from mongo', async function () { - this.expectNullReferenceCleanup(this.oldArchivedProject._id) - this.ProjectMock.expects('updateOne') - .withArgs( - { - _id: this.oldArchivedProject._id, - }, - { - $set: { - archived: [], - }, - $pull: { - collaberator_refs: this.userId, - reviewer_refs: this.userId, - readOnly_refs: this.userId, - pendingEditor_refs: this.userId, - pendingReviewer_refs: this.userId, - tokenAccessReadOnly_refs: this.userId, - tokenAccessReadAndWrite_refs: this.userId, - trashed: this.userId, - }, - } - ) - .resolves() - await this.CollaboratorsHandler.promises.removeUserFromProject( - this.oldArchivedProject._id, - this.userId - ) - }) - }) - - describe('an archived project, archived with an array value', function () { - beforeEach(function () { - this.ProjectMock.expects('findOne') - .withArgs({ - _id: this.archivedProject._id, - }) - .chain('exec') - .resolves(this.archivedProject) - }) - - it('should remove the user from mongo', async function () { - this.expectNullReferenceCleanup(this.archivedProject._id) - this.ProjectMock.expects('updateOne') - .withArgs( - { - _id: this.archivedProject._id, - }, - { - $pull: { - collaberator_refs: this.userId, - reviewer_refs: this.userId, - readOnly_refs: this.userId, - pendingEditor_refs: this.userId, - pendingReviewer_refs: this.userId, - tokenAccessReadOnly_refs: this.userId, - tokenAccessReadAndWrite_refs: this.userId, - archived: this.userId, - trashed: this.userId, - }, - } - ) - .resolves() - await this.CollaboratorsHandler.promises.removeUserFromProject( - this.archivedProject._id, - this.userId - ) - }) - }) }) describe('addUserIdToProject', function () { @@ -539,14 +392,6 @@ describe('CollaboratorsHandler', function () { 'token-read-only-1', ] for (const projectId of expectedProjects) { - this.ProjectMock.expects('findOne') - .withArgs({ - _id: projectId, - }) - .chain('exec') - .resolves({ _id: projectId }) - - this.expectNullReferenceCleanup(projectId) this.ProjectMock.expects('updateOne') .withArgs( { @@ -709,8 +554,6 @@ describe('CollaboratorsHandler', function () { describe('setCollaboratorPrivilegeLevel', function () { it('sets a collaborator to read-only', async function () { - this.expectNullReferenceCleanup(this.project._id) - this.ProjectMock.expects('updateOne') .withArgs( { @@ -741,8 +584,6 @@ describe('CollaboratorsHandler', function () { }) it('sets a collaborator to read-write', async function () { - this.expectNullReferenceCleanup(this.project._id) - this.ProjectMock.expects('updateOne') .withArgs( { @@ -782,8 +623,6 @@ describe('CollaboratorsHandler', function () { }) }) it('should correctly update the project', async function () { - this.expectNullReferenceCleanup(this.project._id) - this.ProjectMock.expects('updateOne') .withArgs( { @@ -827,8 +666,6 @@ describe('CollaboratorsHandler', function () { }) }) it('should correctly update the project', async function () { - this.expectNullReferenceCleanup(this.project._id) - this.ProjectMock.expects('updateOne') .withArgs( { @@ -861,8 +698,6 @@ describe('CollaboratorsHandler', function () { }) it('sets a collaborator to read-only as a pendingEditor', async function () { - this.expectNullReferenceCleanup(this.project._id) - this.ProjectMock.expects('updateOne') .withArgs( { @@ -896,8 +731,6 @@ describe('CollaboratorsHandler', function () { }) it('sets a collaborator to read-only as a pendingReviewer', async function () { - this.expectNullReferenceCleanup(this.project._id) - this.ProjectMock.expects('updateOne') .withArgs( { @@ -931,8 +764,6 @@ describe('CollaboratorsHandler', function () { }) it('throws a NotFoundError if the project or collaborator does not exist', async function () { - this.expectNullReferenceCleanup(this.project._id) - this.ProjectMock.expects('updateOne') .chain('exec') .resolves({ matchedCount: 0 }) diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs b/services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs index edac9c6c92..aafbb0dbc0 100644 --- a/services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs +++ b/services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs @@ -229,8 +229,8 @@ describe('CollaboratorsInviteController', function () { }) describe('when all goes well', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.CollaboratorsInviteGetter.promises.getAllInvites.resolves( ctx.fakeInvites ) @@ -263,8 +263,8 @@ describe('CollaboratorsInviteController', function () { }) describe('when CollaboratorsInviteHandler.getAllInvites produces an error', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.CollaboratorsInviteGetter.promises.getAllInvites.rejects( new Error('woops') ) @@ -378,8 +378,8 @@ describe('CollaboratorsInviteController', function () { }) describe('readAndWrite collaborator', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.privileges = 'readAndWrite' ctx.CollaboratorsInviteController._checkShouldInviteEmail = sinon .stub() @@ -420,8 +420,8 @@ describe('CollaboratorsInviteController', function () { }) describe('readOnly collaborator (always allowed)', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.req.body = { email: ctx.targetEmail, privileges: (ctx.privileges = 'readOnly'), @@ -500,8 +500,8 @@ describe('CollaboratorsInviteController', function () { }) describe('when inviteToProject produces an error', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.CollaboratorsInviteController._checkShouldInviteEmail = sinon .stub() .resolves(true) @@ -559,8 +559,8 @@ describe('CollaboratorsInviteController', function () { }) describe('when _checkShouldInviteEmail disallows the invite', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.CollaboratorsInviteController._checkShouldInviteEmail = sinon .stub() .resolves(false) @@ -601,8 +601,8 @@ describe('CollaboratorsInviteController', function () { }) describe('when _checkShouldInviteEmail produces an error', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.CollaboratorsInviteController._checkShouldInviteEmail = sinon .stub() .rejects(new Error('woops')) @@ -748,8 +748,8 @@ describe('CollaboratorsInviteController', function () { }) describe('when the token is valid', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.res.callback = () => resolve() ctx.CollaboratorsInviteController.viewInvite( ctx.req, @@ -802,8 +802,8 @@ describe('CollaboratorsInviteController', function () { }) describe('when not logged in', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.SessionManager.getSessionUser.returns(null) ctx.res.callback = () => resolve() @@ -833,8 +833,8 @@ describe('CollaboratorsInviteController', function () { }) describe('when user is already a member of the project', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves( true ) @@ -883,8 +883,8 @@ describe('CollaboratorsInviteController', function () { }) describe('when isUserInvitedMemberOfProject produces an error', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.rejects( new Error('woops') ) @@ -927,8 +927,8 @@ describe('CollaboratorsInviteController', function () { }) describe('when the getInviteByToken produces an error', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.CollaboratorsInviteGetter.promises.getInviteByToken.rejects( new Error('woops') ) @@ -974,8 +974,8 @@ describe('CollaboratorsInviteController', function () { }) describe('when the getInviteByToken does not produce an invite', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.CollaboratorsInviteGetter.promises.getInviteByToken.resolves(null) ctx.res.callback = () => resolve() ctx.CollaboratorsInviteController.viewInvite( @@ -1023,8 +1023,8 @@ describe('CollaboratorsInviteController', function () { }) describe('when User.getUser produces an error', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.UserGetter.promises.getUser.rejects(new Error('woops')) ctx.next.callsFake(() => resolve()) ctx.CollaboratorsInviteController.viewInvite( @@ -1068,8 +1068,8 @@ describe('CollaboratorsInviteController', function () { }) describe('when User.getUser does not find a user', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.UserGetter.promises.getUser.resolves(null) ctx.res.callback = () => resolve() ctx.CollaboratorsInviteController.viewInvite( @@ -1117,8 +1117,8 @@ describe('CollaboratorsInviteController', function () { }) describe('when getProject produces an error', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.ProjectGetter.promises.getProject.rejects(new Error('woops')) ctx.next.callsFake(() => resolve()) ctx.CollaboratorsInviteController.viewInvite( @@ -1162,8 +1162,8 @@ describe('CollaboratorsInviteController', function () { }) describe('when Project.getUser does not find a user', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.ProjectGetter.promises.getProject.resolves(null) ctx.res.callback = () => resolve() ctx.CollaboratorsInviteController.viewInvite( @@ -1224,8 +1224,8 @@ describe('CollaboratorsInviteController', function () { describe('when generateNewInvite does not produce an error', function () { describe('and returns an invite object', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.res.callback = () => resolve() ctx.CollaboratorsInviteController.generateNewInvite( ctx.req, @@ -1274,8 +1274,8 @@ describe('CollaboratorsInviteController', function () { }) describe('and returns a null invite', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.CollaboratorsInviteHandler.promises.generateNewInvite.resolves( null ) @@ -1303,8 +1303,8 @@ describe('CollaboratorsInviteController', function () { }) describe('when generateNewInvite produces an error', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.CollaboratorsInviteHandler.promises.generateNewInvite.rejects( new Error('woops') ) @@ -1343,8 +1343,8 @@ describe('CollaboratorsInviteController', function () { }) describe('when revokeInvite does not produce an error', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.res.callback = () => resolve() ctx.CollaboratorsInviteController.revokeInvite( ctx.req, @@ -1387,8 +1387,8 @@ describe('CollaboratorsInviteController', function () { }) describe('when revokeInvite produces an error', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.CollaboratorsInviteHandler.promises.revokeInvite.rejects( new Error('woops') ) @@ -1427,8 +1427,8 @@ describe('CollaboratorsInviteController', function () { }) describe('when acceptInvite does not produce an error', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.res.callback = () => resolve() ctx.CollaboratorsInviteController.acceptInvite( ctx.req, @@ -1476,8 +1476,8 @@ describe('CollaboratorsInviteController', function () { }) describe('when the invite is not found', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.CollaboratorsInviteGetter.promises.getInviteByToken.resolves(null) ctx.next.callsFake(() => resolve()) ctx.CollaboratorsInviteController.acceptInvite( @@ -1496,8 +1496,8 @@ describe('CollaboratorsInviteController', function () { }) describe('when acceptInvite produces an error', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.CollaboratorsInviteHandler.promises.acceptInvite.rejects( new Error('woops') ) @@ -1527,8 +1527,8 @@ describe('CollaboratorsInviteController', function () { }) describe('when the project audit log entry fails', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.ProjectAuditLogHandler.promises.addEntry.rejects( new Error('oops') ) diff --git a/services/web/test/unit/src/Collaborators/OwnershipTransferHandlerTests.js b/services/web/test/unit/src/Collaborators/OwnershipTransferHandlerTests.js index 3ef77e3500..4994a3f129 100644 --- a/services/web/test/unit/src/Collaborators/OwnershipTransferHandlerTests.js +++ b/services/web/test/unit/src/Collaborators/OwnershipTransferHandlerTests.js @@ -19,12 +19,17 @@ describe('OwnershipTransferHandler', function () { _id: new ObjectId(), email: 'readonly@example.com', } + this.reviewer = { + _id: new ObjectId(), + email: 'reviewer@example.com', + } this.project = { _id: new ObjectId(), name: 'project', owner_ref: this.user._id, collaberator_refs: [this.collaborator._id], readOnly_refs: [this.readOnlyCollaborator._id], + reviewer_refs: [this.reviewer._id], } this.ProjectGetter = { promises: { @@ -97,6 +102,9 @@ describe('OwnershipTransferHandler', function () { this.UserGetter.promises.getUser .withArgs(this.readOnlyCollaborator._id) .resolves(this.readOnlyCollaborator) + this.UserGetter.promises.getUser + .withArgs(this.reviewer._id) + .resolves(this.reviewer) }) it("should return a not found error if the project can't be found", async function () { @@ -200,6 +208,32 @@ describe('OwnershipTransferHandler', function () { ) }) + it('should transfer ownership of the project to a reviewer', async function () { + await this.handler.promises.transferOwnership( + this.project._id, + this.reviewer._id + ) + expect(this.ProjectModel.updateOne).to.have.been.calledWith( + { _id: this.project._id }, + sinon.match({ $set: { owner_ref: this.reviewer._id } }) + ) + }) + + it('gives old owner reviewer permissions if new owner was previously a reviewer', async function () { + await this.handler.promises.transferOwnership( + this.project._id, + this.reviewer._id + ) + expect( + this.CollaboratorsHandler.promises.addUserIdToProject + ).to.have.been.calledWith( + this.project._id, + this.reviewer._id, + this.user._id, + PrivilegeLevels.REVIEW + ) + }) + it('should flush the project to tpds', async function () { await this.handler.promises.transferOwnership( this.project._id, diff --git a/services/web/test/unit/src/Contact/ContactController.test.mjs b/services/web/test/unit/src/Contact/ContactController.test.mjs index 13f70c81f6..06dd5b768a 100644 --- a/services/web/test/unit/src/Contact/ContactController.test.mjs +++ b/services/web/test/unit/src/Contact/ContactController.test.mjs @@ -88,8 +88,8 @@ describe('ContactController', function () { ctx.ContactController.getContacts(ctx.req, ctx.res) }) - it('should populate the users contacts ids', function (ctx) { - return new Promise(resolve => { + it('should populate the users contacts ids', async function (ctx) { + await new Promise((resolve, reject) => { ctx.res.callback = () => { expect(ctx.UserGetter.promises.getUsers).to.have.been.calledWith( ctx.contact_ids, @@ -102,12 +102,16 @@ describe('ContactController', function () { ) resolve() } - ctx.ContactController.getContacts(ctx.req, ctx.res, resolve) + ctx.ContactController.getContacts( + ctx.req, + ctx.res, + ctx.rejectOnError(reject) + ) }) }) - it('should fire the getContact module hook', function (ctx) { - return new Promise(resolve => { + it('should fire the getContact module hook', async function (ctx) { + await new Promise((resolve, reject) => { ctx.res.callback = () => { expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith( 'getContacts', @@ -115,12 +119,16 @@ describe('ContactController', function () { ) resolve() } - ctx.ContactController.getContacts(ctx.req, ctx.res, resolve) + ctx.ContactController.getContacts( + ctx.req, + ctx.res, + ctx.rejectOnError(reject) + ) }) }) - it('should return a formatted list of contacts in contact list order, without holding accounts', function (ctx) { - return new Promise(resolve => { + it('should return a formatted list of contacts in contact list order, without holding accounts', async function (ctx) { + await new Promise((resolve, reject) => { ctx.res.callback = () => { ctx.res.json.args[0][0].contacts.should.deep.equal([ { @@ -140,7 +148,11 @@ describe('ContactController', function () { ]) resolve() } - ctx.ContactController.getContacts(ctx.req, ctx.res, resolve) + ctx.ContactController.getContacts( + ctx.req, + ctx.res, + ctx.rejectOnError(reject) + ) }) }) }) diff --git a/services/web/test/unit/src/Documents/DocumentController.test.mjs b/services/web/test/unit/src/Documents/DocumentController.test.mjs index 06c971be91..e3fe3bdec2 100644 --- a/services/web/test/unit/src/Documents/DocumentController.test.mjs +++ b/services/web/test/unit/src/Documents/DocumentController.test.mjs @@ -125,8 +125,8 @@ describe('DocumentController', function () { }) describe('when project exists with project history enabled', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.res.callback = err => { resolve(err) } @@ -151,8 +151,8 @@ describe('DocumentController', function () { }) describe('when the project does not exist', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.ProjectGetter.promises.getProject.resolves(null) ctx.res.callback = err => { resolve(err) @@ -176,8 +176,8 @@ describe('DocumentController', function () { }) describe('when the document exists', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.req.body = { lines: ctx.doc_lines, version: ctx.version, @@ -211,8 +211,8 @@ describe('DocumentController', function () { }) describe("when the document doesn't exist", function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.ProjectEntityUpdateHandler.promises.updateDocLines.rejects( new Errors.NotFoundError('document does not exist') ) diff --git a/services/web/test/unit/src/Downloads/ProjectDownloadsController.test.mjs b/services/web/test/unit/src/Downloads/ProjectDownloadsController.test.mjs index 1e339097fa..f3737df0ab 100644 --- a/services/web/test/unit/src/Downloads/ProjectDownloadsController.test.mjs +++ b/services/web/test/unit/src/Downloads/ProjectDownloadsController.test.mjs @@ -52,7 +52,7 @@ describe('ProjectDownloadsController', function () { .stub() .callsArgWith(1, null, ctx.stream) ctx.req.params = { Project_id: ctx.project_id } - ctx.project_name = 'project name with accênts' + ctx.project_name = 'project name with accênts and % special characters' ctx.ProjectGetter.getProject = sinon .stub() .callsArgWith(2, null, { name: ctx.project_name }) @@ -95,9 +95,9 @@ describe('ProjectDownloadsController', function () { .should.equal(true) }) - it('should name the downloaded file after the project', function (ctx) { + it('should name the downloaded file after the project but sanitise special characters', function (ctx) { ctx.res.headers.should.deep.equal({ - 'Content-Disposition': `attachment; filename="${ctx.project_name}.zip"`, + 'Content-Disposition': `attachment; filename="project_name_with_accênts_and___special_characters.zip"`, 'Content-Type': 'application/zip', 'X-Content-Type-Options': 'nosniff', }) diff --git a/services/web/test/unit/src/Downloads/ProjectZipStreamManager.test.mjs b/services/web/test/unit/src/Downloads/ProjectZipStreamManager.test.mjs index df7486e11d..deb9275c5f 100644 --- a/services/web/test/unit/src/Downloads/ProjectZipStreamManager.test.mjs +++ b/services/web/test/unit/src/Downloads/ProjectZipStreamManager.test.mjs @@ -72,8 +72,8 @@ describe('ProjectZipStreamManager', function () { describe('createZipStreamForMultipleProjects', function () { describe('successfully', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.project_ids = ['project-1', 'project-2'] ctx.zip_streams = { 'project-1': new EventEmitter(), @@ -154,8 +154,8 @@ describe('ProjectZipStreamManager', function () { }) describe('with a project not existing', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.project_ids = ['project-1', 'wrong-id'] ctx.project_names = { 'project-1': 'Project One Name', @@ -346,8 +346,8 @@ describe('ProjectZipStreamManager', function () { }) describe('addAllDocsToArchive', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.docs = { '/main.tex': { lines: [ diff --git a/services/web/test/unit/src/Exports/ExportsController.test.mjs b/services/web/test/unit/src/Exports/ExportsController.test.mjs index cd8f4ba7a9..9e92a8c6d5 100644 --- a/services/web/test/unit/src/Exports/ExportsController.test.mjs +++ b/services/web/test/unit/src/Exports/ExportsController.test.mjs @@ -65,8 +65,8 @@ describe('ExportsController', function () { }) describe('without gallery fields', function () { - it('should ask the handler to perform the export', function (ctx) { - return new Promise(resolve => { + it('should ask the handler to perform the export', async function (ctx) { + await new Promise(resolve => { ctx.handler.exportProject = sinon .stub() .yields(null, { iAmAnExport: true, v1_id: 897 }) @@ -92,8 +92,8 @@ describe('ExportsController', function () { }) describe('with a message from v1', function () { - it('should ask the handler to perform the export', function (ctx) { - return new Promise(resolve => { + it('should ask the handler to perform the export', async function (ctx) { + await new Promise(resolve => { ctx.handler.exportProject = sinon.stub().yields(null, { iAmAnExport: true, v1_id: 897, @@ -129,8 +129,8 @@ describe('ExportsController', function () { return (ctx.req.body.showSource = true) }) - it('should ask the handler to perform the export', function (ctx) { - return new Promise(resolve => { + it('should ask the handler to perform the export', async function (ctx) { + await new Promise(resolve => { ctx.handler.exportProject = sinon .stub() .yields(null, { iAmAnExport: true, v1_id: 897 }) @@ -161,8 +161,8 @@ describe('ExportsController', function () { }) describe('with an error return from v1 to forward to the publish modal', function () { - it('should forward the response onward', function (ctx) { - return new Promise(resolve => { + it('should forward the response onward', async function (ctx) { + await new Promise(resolve => { ctx.error_json = { status: 422, message: 'nope' } ctx.handler.exportProject = sinon .stub() @@ -175,8 +175,8 @@ describe('ExportsController', function () { }) }) - it('should ask the handler to return the status of an export', function (ctx) { - return new Promise(resolve => { + it('should ask the handler to return the status of an export', async function (ctx) { + await new Promise(resolve => { ctx.handler.fetchExport = sinon.stub().yields( null, `{ diff --git a/services/web/test/unit/src/Exports/ExportsHandler.test.mjs b/services/web/test/unit/src/Exports/ExportsHandler.test.mjs index a7944beced..05523834a6 100644 --- a/services/web/test/unit/src/Exports/ExportsHandler.test.mjs +++ b/services/web/test/unit/src/Exports/ExportsHandler.test.mjs @@ -81,8 +81,8 @@ describe('ExportsHandler', function () { }) describe('when all goes well', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.ExportsHandler.exportProject( ctx.export_params, (error, exportData) => { @@ -111,8 +111,8 @@ describe('ExportsHandler', function () { }) describe("when request can't be built", function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.ExportsHandler._buildExport = sinon .stub() .yields(new Error('cannot export project without root doc')) @@ -132,8 +132,8 @@ describe('ExportsHandler', function () { }) describe('when export request returns an error to forward to the user', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.error_json = { status: 422, message: 'nope' } ctx.ExportsHandler._requestExport = sinon .stub() @@ -158,8 +158,8 @@ describe('ExportsHandler', function () { }) describe('_buildExport', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.project = { id: ctx.project_id, rootDoc_id: 'doc1_id', @@ -202,8 +202,8 @@ describe('ExportsHandler', function () { }) describe('when all goes well', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.ExportsHandler._buildExport( ctx.export_params, (error, exportData) => { @@ -262,8 +262,8 @@ describe('ExportsHandler', function () { }) describe('when we send replacement user first and last name', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.custom_first_name = 'FIRST' ctx.custom_last_name = 'LAST' ctx.export_params.first_name = ctx.custom_first_name @@ -316,8 +316,8 @@ describe('ExportsHandler', function () { }) describe('when project is not found', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.ProjectGetter.getProject = sinon .stub() .yields(new Error('project not found')) @@ -338,8 +338,8 @@ describe('ExportsHandler', function () { describe('when project has no root doc', function () { describe('when a root doc can be set automatically', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.project.rootDoc_id = null ctx.ProjectLocator.findRootDoc = sinon .stub() @@ -400,8 +400,8 @@ describe('ExportsHandler', function () { describe('when project has an invalid root doc', function () { describe('when a new root doc can be set automatically', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.fakeDoc_id = '1a2b3c4d5e6f' ctx.project.rootDoc_id = ctx.fakeDoc_id ctx.ProjectLocator.findRootDoc = sinon @@ -461,8 +461,8 @@ describe('ExportsHandler', function () { }) describe('when no root doc can be identified', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.ProjectLocator.findRootDoc = sinon .stub() .yields(null, [null, null]) @@ -483,8 +483,8 @@ describe('ExportsHandler', function () { }) describe('when user is not found', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.UserGetter.getUser = sinon .stub() .yields(new Error('user not found')) @@ -504,8 +504,8 @@ describe('ExportsHandler', function () { }) describe('when project history request fails', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.ExportsHandler._requestVersion = sinon .stub() .yields(new Error('project history call failed')) @@ -526,8 +526,8 @@ describe('ExportsHandler', function () { }) describe('_requestExport', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.settings.apis = { v1: { url: 'http://127.0.0.1:5000', @@ -546,8 +546,8 @@ describe('ExportsHandler', function () { }) describe('when all goes well', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.stubRequest.post = ctx.stubPost ctx.ExportsHandler._requestExport( ctx.export_data, @@ -579,8 +579,8 @@ describe('ExportsHandler', function () { }) describe('when the request fails', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.stubRequest.post = sinon .stub() .yields(new Error('export request failed')) @@ -600,13 +600,14 @@ describe('ExportsHandler', function () { }) describe('when the request returns an error response to forward', function () { - beforeEach(function (ctx) { + beforeEach(async function (ctx) { ctx.error_code = 422 ctx.error_json = { status: ctx.error_code, message: 'nope' } ctx.stubRequest.post = sinon .stub() .yields(null, { statusCode: ctx.error_code }, ctx.error_json) - return new Promise(resolve => { + + await new Promise(resolve => { ctx.ExportsHandler._requestExport( ctx.export_data, (error, exportV1Id) => { @@ -627,8 +628,8 @@ describe('ExportsHandler', function () { }) describe('fetchExport', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.settings.apis = { v1: { url: 'http://127.0.0.1:5000', @@ -647,8 +648,8 @@ describe('ExportsHandler', function () { }) describe('when all goes well', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.stubRequest.get = ctx.stubGet ctx.ExportsHandler.fetchExport(ctx.export_id, (error, body) => { ctx.callback(error, body) @@ -678,8 +679,8 @@ describe('ExportsHandler', function () { }) describe('fetchDownload', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.settings.apis = { v1: { url: 'http://127.0.0.1:5000', @@ -699,8 +700,8 @@ describe('ExportsHandler', function () { }) describe('when all goes well', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.stubRequest.get = ctx.stubGet ctx.ExportsHandler.fetchDownload( ctx.export_id, diff --git a/services/web/test/unit/src/FileStore/FileStoreController.test.mjs b/services/web/test/unit/src/FileStore/FileStoreController.test.mjs index ba0670d49c..2119c72a53 100644 --- a/services/web/test/unit/src/FileStore/FileStoreController.test.mjs +++ b/services/web/test/unit/src/FileStore/FileStoreController.test.mjs @@ -214,8 +214,8 @@ describe('FileStoreController', function () { ctx.ProjectLocator.promises.findElement.resolves({ element: ctx.file }) }) - it('reports the file size', function (ctx) { - return new Promise(resolve => { + it('reports the file size', async function (ctx) { + await new Promise(resolve => { const expectedFileSize = 99393 ctx.FileStoreHandler.promises.getFileSize.rejects( new Error('getFileSize: unexpected arguments') @@ -237,8 +237,8 @@ describe('FileStoreController', function () { }) }) - it('returns 404 on NotFoundError', function (ctx) { - return new Promise(resolve => { + it('returns 404 on NotFoundError', async function (ctx) { + await new Promise(resolve => { ctx.FileStoreHandler.promises.getFileSize.rejects( new Errors.NotFoundError() ) diff --git a/services/web/test/unit/src/LinkedFiles/LinkedFilesController.test.mjs b/services/web/test/unit/src/LinkedFiles/LinkedFilesController.test.mjs index e712d17198..d75a853c84 100644 --- a/services/web/test/unit/src/LinkedFiles/LinkedFilesController.test.mjs +++ b/services/web/test/unit/src/LinkedFiles/LinkedFilesController.test.mjs @@ -132,8 +132,8 @@ describe('LinkedFilesController', function () { ctx.next = sinon.stub() }) - it('sets importedAt timestamp on linkedFileData', function (ctx) { - return new Promise(resolve => { + it('sets importedAt timestamp on linkedFileData', async function (ctx) { + await new Promise(resolve => { ctx.next = sinon.stub().callsFake(() => resolve('unexpected error')) ctx.res = { json: () => { @@ -177,8 +177,8 @@ describe('LinkedFilesController', function () { ctx.next = sinon.stub() }) - it('resets importedAt timestamp on linkedFileData', function (ctx) { - return new Promise(resolve => { + it('resets importedAt timestamp on linkedFileData', async function (ctx) { + await new Promise(resolve => { ctx.next = sinon.stub().callsFake(() => resolve('unexpected error')) ctx.res = { json: () => { diff --git a/services/web/test/unit/src/Notifications/NotificationsController.test.mjs b/services/web/test/unit/src/Notifications/NotificationsController.test.mjs index 1bc5c51b31..43d9c0c07e 100644 --- a/services/web/test/unit/src/Notifications/NotificationsController.test.mjs +++ b/services/web/test/unit/src/Notifications/NotificationsController.test.mjs @@ -52,8 +52,8 @@ describe('NotificationsController', function () { ctx.controller = (await import(modulePath)).default }) - it('should ask the handler for all unread notifications', function (ctx) { - return new Promise(resolve => { + it('should ask the handler for all unread notifications', async function (ctx) { + await new Promise(resolve => { const allNotifications = [{ _id: notificationId, user_id: userId }] ctx.handler.getUserNotifications = sinon .stub() @@ -68,8 +68,8 @@ describe('NotificationsController', function () { }) }) - it('should send a delete request when a delete has been received to mark a notification', function (ctx) { - return new Promise(resolve => { + it('should send a delete request when a delete has been received to mark a notification', async function (ctx) { + await new Promise(resolve => { ctx.controller.markNotificationAsRead(ctx.req, { sendStatus: () => { ctx.handler.markAsRead @@ -81,8 +81,8 @@ describe('NotificationsController', function () { }) }) - it('should get a notification by notification id', function (ctx) { - return new Promise(resolve => { + it('should get a notification by notification id', async function (ctx) { + await new Promise(resolve => { const notification = { _id: notificationId, user_id: userId } ctx.handler.getUserNotifications = sinon .stub() diff --git a/services/web/test/unit/src/PasswordReset/PasswordResetController.test.mjs b/services/web/test/unit/src/PasswordReset/PasswordResetController.test.mjs index 05bbfdb433..d225c7ff81 100644 --- a/services/web/test/unit/src/PasswordReset/PasswordResetController.test.mjs +++ b/services/web/test/unit/src/PasswordReset/PasswordResetController.test.mjs @@ -59,11 +59,6 @@ describe('PasswordResetController', function () { removeReconfirmFlag: sinon.stub().resolves(), }, } - ctx.SplitTestHandler = { - promises: { - getAssignment: sinon.stub().resolves('default'), - }, - } vi.doMock('@overleaf/settings', () => ({ default: ctx.settings, @@ -112,19 +107,12 @@ describe('PasswordResetController', function () { default: ctx.UserUpdater, })) - vi.doMock( - '../../../../app/src/Features/SplitTests/SplitTestHandler', - () => ({ - default: ctx.SplitTestHandler, - }) - ) - ctx.PasswordResetController = (await import(MODULE_PATH)).default }) describe('requestReset', function () { - it('should tell the handler to process that email', function (ctx) { - return new Promise(resolve => { + it('should tell the handler to process that email', async function (ctx) { + await new Promise(resolve => { ctx.PasswordResetHandler.promises.generateAndEmailResetToken.resolves( 'primary' ) @@ -141,8 +129,8 @@ describe('PasswordResetController', function () { }) }) - it('should send a 500 if there is an error', function (ctx) { - return new Promise(resolve => { + it('should send a 500 if there is an error', async function (ctx) { + await new Promise(resolve => { ctx.PasswordResetHandler.promises.generateAndEmailResetToken.rejects( new Error('error') ) @@ -153,8 +141,8 @@ describe('PasswordResetController', function () { }) }) - it("should send a 404 if the email doesn't exist", function (ctx) { - return new Promise(resolve => { + it("should send a 404 if the email doesn't exist", async function (ctx) { + await new Promise(resolve => { ctx.PasswordResetHandler.promises.generateAndEmailResetToken.resolves( null ) @@ -167,8 +155,8 @@ describe('PasswordResetController', function () { }) }) - it('should send a 404 if the email is registered as a secondard email', function (ctx) { - return new Promise(resolve => { + it('should send a 404 if the email is registered as a secondard email', async function (ctx) { + await new Promise(resolve => { ctx.PasswordResetHandler.promises.generateAndEmailResetToken.resolves( 'secondary' ) @@ -181,8 +169,8 @@ describe('PasswordResetController', function () { }) }) - it('should normalize the email address', function (ctx) { - return new Promise(resolve => { + it('should normalize the email address', async function (ctx) { + await new Promise(resolve => { ctx.email = ' UPperCaseEMAILWithSpacesAround@example.Com ' ctx.req.body.email = ctx.email ctx.PasswordResetHandler.promises.generateAndEmailResetToken.resolves( @@ -203,8 +191,8 @@ describe('PasswordResetController', function () { ctx.req.session.resetToken = ctx.token }) - it('should tell the user handler to reset the password', function (ctx) { - return new Promise(resolve => { + it('should tell the user handler to reset the password', async function (ctx) { + await new Promise(resolve => { ctx.res.sendStatus = code => { code.should.equal(200) ctx.PasswordResetHandler.promises.setNewUserPassword @@ -216,8 +204,8 @@ describe('PasswordResetController', function () { }) }) - it('should preserve spaces in the password', function (ctx) { - return new Promise(resolve => { + it('should preserve spaces in the password', async function (ctx) { + await new Promise(resolve => { ctx.password = ctx.req.body.password = ' oh! clever! spaces around! ' ctx.res.sendStatus = code => { code.should.equal(200) @@ -231,8 +219,8 @@ describe('PasswordResetController', function () { }) }) - it('should send 404 if the token was not found', function (ctx) { - return new Promise(resolve => { + it('should send 404 if the token was not found', async function (ctx) { + await new Promise(resolve => { ctx.PasswordResetHandler.promises.setNewUserPassword.resolves({ found: false, reset: false, @@ -250,8 +238,8 @@ describe('PasswordResetController', function () { }) }) - it('should return 500 if not reset', function (ctx) { - return new Promise(resolve => { + it('should return 500 if not reset', async function (ctx) { + await new Promise(resolve => { ctx.PasswordResetHandler.promises.setNewUserPassword.resolves({ found: true, reset: false, @@ -269,8 +257,8 @@ describe('PasswordResetController', function () { }) }) - it('should return 400 (Bad Request) if there is no password', function (ctx) { - return new Promise(resolve => { + it('should return 400 (Bad Request) if there is no password', async function (ctx) { + await new Promise(resolve => { ctx.req.body.password = '' ctx.res.status = code => { code.should.equal(400) @@ -287,8 +275,8 @@ describe('PasswordResetController', function () { }) }) - it('should return 400 (Bad Request) if there is no passwordResetToken', function (ctx) { - return new Promise(resolve => { + it('should return 400 (Bad Request) if there is no passwordResetToken', async function (ctx) { + await new Promise(resolve => { ctx.req.body.passwordResetToken = '' ctx.res.status = code => { code.should.equal(400) @@ -305,8 +293,8 @@ describe('PasswordResetController', function () { }) }) - it('should return 400 (Bad Request) if the password is invalid', function (ctx) { - return new Promise(resolve => { + it('should return 400 (Bad Request) if the password is invalid', async function (ctx) { + await new Promise(resolve => { ctx.req.body.password = 'correct horse battery staple' const err = new Error('bad') err.name = 'InvalidPasswordError' @@ -326,8 +314,8 @@ describe('PasswordResetController', function () { }) }) - it('should clear sessions', function (ctx) { - return new Promise(resolve => { + it('should clear sessions', async function (ctx) { + await new Promise(resolve => { ctx.res.sendStatus = code => { ctx.UserSessionsManager.promises.removeSessionsFromRedis.callCount.should.equal( 1 @@ -338,8 +326,8 @@ describe('PasswordResetController', function () { }) }) - it('should call removeReconfirmFlag if user.must_reconfirm', function (ctx) { - return new Promise(resolve => { + it('should call removeReconfirmFlag if user.must_reconfirm', async function (ctx) { + await new Promise(resolve => { ctx.res.sendStatus = code => { ctx.UserUpdater.promises.removeReconfirmFlag.callCount.should.equal(1) resolve() @@ -349,8 +337,8 @@ describe('PasswordResetController', function () { }) describe('catch errors', function () { - it('should return 404 for NotFoundError', function (ctx) { - return new Promise(resolve => { + it('should return 404 for NotFoundError', async function (ctx) { + await new Promise(resolve => { const anError = new Error('oops') anError.name = 'NotFoundError' ctx.PasswordResetHandler.promises.setNewUserPassword.rejects(anError) @@ -365,8 +353,8 @@ describe('PasswordResetController', function () { ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res) }) }) - it('should return 400 for InvalidPasswordError', function (ctx) { - return new Promise(resolve => { + it('should return 400 for InvalidPasswordError', async function (ctx) { + await new Promise(resolve => { const anError = new Error('oops') anError.name = 'InvalidPasswordError' ctx.PasswordResetHandler.promises.setNewUserPassword.rejects(anError) @@ -381,8 +369,8 @@ describe('PasswordResetController', function () { ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res) }) }) - it('should return 500 for other errors', function (ctx) { - return new Promise(resolve => { + it('should return 500 for other errors', async function (ctx) { + await new Promise(resolve => { const anError = new Error('oops') ctx.PasswordResetHandler.promises.setNewUserPassword.rejects(anError) ctx.res.status = code => { @@ -412,8 +400,8 @@ describe('PasswordResetController', function () { ctx.req.session.doLoginAfterPasswordReset = 'true' }) - it('should login user', function (ctx) { - return new Promise(resolve => { + it('should login user', async function (ctx) { + await new Promise(resolve => { ctx.AuthenticationController.finishLogin.callsFake((...args) => { expect(args[0]).to.equal(ctx.user) resolve() @@ -430,8 +418,8 @@ describe('PasswordResetController', function () { ctx.req.query.passwordResetToken = ctx.token }) - it('should set session.resetToken and redirect', function (ctx) { - return new Promise(resolve => { + it('should set session.resetToken and redirect', async function (ctx) { + await new Promise(resolve => { ctx.req.session.should.not.have.property('resetToken') ctx.res.redirect = path => { path.should.equal('/user/password/set') @@ -452,8 +440,8 @@ describe('PasswordResetController', function () { .resolves({ user: { _id: ctx.user_id }, remainingPeeks: 0 }) }) - it('should redirect to the reset request page with an error message', function (ctx) { - return new Promise(resolve => { + it('should redirect to the reset request page with an error message', async function (ctx) { + await new Promise(resolve => { ctx.res.redirect = path => { path.should.equal('/user/password/reset?error=token_expired') ctx.req.session.should.not.have.property('resetToken') @@ -473,8 +461,8 @@ describe('PasswordResetController', function () { ctx.req.query.email = 'foo@bar.com' }) - it('should set session.resetToken and redirect with email', function (ctx) { - return new Promise(resolve => { + it('should set session.resetToken and redirect with email', async function (ctx) { + await new Promise(resolve => { ctx.req.session.should.not.have.property('resetToken') ctx.res.redirect = path => { path.should.equal('/user/password/set?email=foo%40bar.com') @@ -492,8 +480,8 @@ describe('PasswordResetController', function () { ctx.req.query.email = 'not-an-email' }) - it('should set session.resetToken and redirect without email', function (ctx) { - return new Promise(resolve => { + it('should set session.resetToken and redirect without email', async function (ctx) { + await new Promise(resolve => { ctx.req.session.should.not.have.property('resetToken') ctx.res.redirect = path => { path.should.equal('/user/password/set') @@ -511,8 +499,8 @@ describe('PasswordResetController', function () { ctx.req.query.email = { foo: 'bar' } }) - it('should set session.resetToken and redirect without email', function (ctx) { - return new Promise(resolve => { + it('should set session.resetToken and redirect without email', async function (ctx) { + await new Promise(resolve => { ctx.req.session.should.not.have.property('resetToken') ctx.res.redirect = path => { path.should.equal('/user/password/set') @@ -530,8 +518,8 @@ describe('PasswordResetController', function () { ctx.req.session.resetToken = ctx.token }) - it('should render the page, passing the reset token', function (ctx) { - return new Promise(resolve => { + it('should render the page, passing the reset token', async function (ctx) { + await new Promise(resolve => { ctx.res.render = (templatePath, options) => { options.passwordResetToken.should.equal(ctx.token) resolve() @@ -540,8 +528,8 @@ describe('PasswordResetController', function () { }) }) - it('should clear the req.session.resetToken', function (ctx) { - return new Promise(resolve => { + it('should clear the req.session.resetToken', async function (ctx) { + await new Promise(resolve => { ctx.res.render = (templatePath, options) => { ctx.req.session.should.not.have.property('resetToken') resolve() @@ -552,8 +540,8 @@ describe('PasswordResetController', function () { }) describe('without a token in session', function () { - it('should redirect to the reset request page', function (ctx) { - return new Promise(resolve => { + it('should redirect to the reset request page', async function (ctx) { + await new Promise(resolve => { ctx.res.redirect = path => { path.should.equal('/user/password/reset') ctx.req.session.should.not.have.property('resetToken') diff --git a/services/web/test/unit/src/PasswordReset/PasswordResetHandler.test.mjs b/services/web/test/unit/src/PasswordReset/PasswordResetHandler.test.mjs index aab46ae2bf..0eb52c4410 100644 --- a/services/web/test/unit/src/PasswordReset/PasswordResetHandler.test.mjs +++ b/services/web/test/unit/src/PasswordReset/PasswordResetHandler.test.mjs @@ -103,8 +103,8 @@ describe('PasswordResetHandler', function () { ) }) - it('should send the email with the token', function (ctx) { - return new Promise(resolve => { + it('should send the email with the token', async function (ctx) { + await new Promise(resolve => { ctx.UserGetter.promises.getUserByAnyEmail.resolves(ctx.user) ctx.OneTimeTokenHandler.promises.getNewToken.resolves(ctx.token) ctx.EmailHandler.promises.sendEmail.resolves() @@ -127,8 +127,8 @@ describe('PasswordResetHandler', function () { }) }) - it('should return errors from getUserByAnyEmail', function (ctx) { - return new Promise(resolve => { + it('should return errors from getUserByAnyEmail', async function (ctx) { + await new Promise(resolve => { const err = new Error('oops') ctx.UserGetter.promises.getUserByAnyEmail.rejects(err) ctx.PasswordResetHandler.generateAndEmailResetToken( @@ -273,8 +273,8 @@ describe('PasswordResetHandler', function () { .yields(null, null) }) - it('should return found == false and reset == false', function (ctx) { - return new Promise(resolve => { + it('should return found == false and reset == false', async function (ctx) { + await new Promise(resolve => { ctx.PasswordResetHandler.setNewUserPassword( ctx.token, ctx.password, @@ -302,8 +302,8 @@ describe('PasswordResetHandler', function () { ctx.OneTimeTokenHandler.expireToken.callsArgWith(2, null) }) - it('should return found == false and reset == false', function (ctx) { - return new Promise(resolve => { + it('should return found == false and reset == false', async function (ctx) { + await new Promise(resolve => { ctx.PasswordResetHandler.setNewUserPassword( ctx.token, ctx.password, @@ -332,8 +332,8 @@ describe('PasswordResetHandler', function () { .callsArgWith(2, null) }) - it('should update the user audit log', function (ctx) { - return new Promise(resolve => { + it('should update the user audit log', async function (ctx) { + await new Promise(resolve => { ctx.PasswordResetHandler.setNewUserPassword( ctx.token, ctx.password, @@ -354,8 +354,8 @@ describe('PasswordResetHandler', function () { }) }) - it('should return reset == true and the user id', function (ctx) { - return new Promise(resolve => { + it('should return reset == true and the user id', async function (ctx) { + await new Promise(resolve => { ctx.PasswordResetHandler.setNewUserPassword( ctx.token, ctx.password, @@ -371,8 +371,8 @@ describe('PasswordResetHandler', function () { }) }) - it('should expire the token', function (ctx) { - return new Promise(resolve => { + it('should expire the token', async function (ctx) { + await new Promise(resolve => { ctx.PasswordResetHandler.setNewUserPassword( ctx.token, ctx.password, @@ -391,8 +391,8 @@ describe('PasswordResetHandler', function () { beforeEach(function (ctx) { ctx.auditLog.initiatorId = ctx.user_id }) - it('should update the user audit log with initiatorId', function (ctx) { - return new Promise(resolve => { + it('should update the user audit log with initiatorId', async function (ctx) { + await new Promise(resolve => { ctx.PasswordResetHandler.setNewUserPassword( ctx.token, ctx.password, @@ -424,8 +424,8 @@ describe('PasswordResetHandler', function () { .withArgs(ctx.user, ctx.password) .rejects() }) - it('should return the error', function (ctx) { - return new Promise(resolve => { + it('should return the error', async function (ctx) { + await new Promise(resolve => { ctx.PasswordResetHandler.setNewUserPassword( ctx.token, ctx.password, @@ -450,8 +450,8 @@ describe('PasswordResetHandler', function () { new Error('oops') ) }) - it('should return the error', function (ctx) { - return new Promise(resolve => { + it('should return the error', async function (ctx) { + await new Promise(resolve => { ctx.PasswordResetHandler.setNewUserPassword( ctx.token, ctx.password, @@ -495,8 +495,8 @@ describe('PasswordResetHandler', function () { .yields(null, null) }) - it('should return reset == false', function (ctx) { - return new Promise(resolve => { + it('should return reset == false', async function (ctx) { + await new Promise(resolve => { ctx.PasswordResetHandler.setNewUserPassword( ctx.token, ctx.password, @@ -524,8 +524,8 @@ describe('PasswordResetHandler', function () { }) }) - it('should return reset == false', function (ctx) { - return new Promise(resolve => { + it('should return reset == false', async function (ctx) { + await new Promise(resolve => { ctx.PasswordResetHandler.setNewUserPassword( ctx.token, ctx.password, @@ -549,8 +549,8 @@ describe('PasswordResetHandler', function () { ctx.UserGetter.promises.getUserByMainEmail.resolves(ctx.user) }) - it('should return reset == true and the user id', function (ctx) { - return new Promise(resolve => { + it('should return reset == true and the user id', async function (ctx) { + await new Promise(resolve => { ctx.PasswordResetHandler.setNewUserPassword( ctx.token, ctx.password, diff --git a/services/web/test/unit/src/Project/ProjectApiController.test.mjs b/services/web/test/unit/src/Project/ProjectApiController.test.mjs index c73f327cd2..f23a81280e 100644 --- a/services/web/test/unit/src/Project/ProjectApiController.test.mjs +++ b/services/web/test/unit/src/Project/ProjectApiController.test.mjs @@ -30,8 +30,8 @@ describe('Project api controller', function () { }) describe('getProjectDetails', function () { - it('should ask the project details handler for proj details', function (ctx) { - return new Promise(resolve => { + it('should ask the project details handler for proj details', async function (ctx) { + await new Promise(resolve => { ctx.ProjectDetailsHandler.getDetails.callsArgWith( 1, null, diff --git a/services/web/test/unit/src/Project/ProjectDeleterTests.js b/services/web/test/unit/src/Project/ProjectDeleterTests.js index 20f8cf2ead..ee01037d14 100644 --- a/services/web/test/unit/src/Project/ProjectDeleterTests.js +++ b/services/web/test/unit/src/Project/ProjectDeleterTests.js @@ -36,6 +36,7 @@ describe('ProjectDeleter', function () { deleterId: '588f3ddae8ebc1bac07c9fa4', deleterIpAddress: '172.19.0.1', deletedProjectId: '5cf9270b4eff6e186cf8b05e', + deletedProjectOwnerId: this.user._id, }, project: { _id: '5cf9270b4eff6e186cf8b05e', @@ -94,10 +95,6 @@ describe('ProjectDeleter', function () { }, } - this.ProjectHelper = { - calculateArchivedArray: sinon.stub(), - } - this.db = { projects: { insertOne: sinon.stub().resolves(), @@ -142,7 +139,6 @@ describe('ProjectDeleter', function () { '../../infrastructure/Features': this.Features, '../Editor/EditorRealTimeController': this.EditorRealTimeController, '../../models/Project': { Project }, - './ProjectHelper': this.ProjectHelper, '../../models/DeletedProject': { DeletedProject }, '../DocumentUpdater/DocumentUpdaterHandler': this.DocumentUpdaterHandler, @@ -501,6 +497,16 @@ describe('ProjectDeleter', function () { projectId: this.deletedProjects[0].project._id, }) }) + + it('should log a completed deletion', async function () { + expect(this.logger.info).to.have.been.calledWith( + { + projectId: this.deletedProjects[0].project._id, + userId: this.user._id, + }, + 'expired deleted project successfully' + ) + }) }) describe('on an active project (from an incomplete delete)', function () { @@ -548,19 +554,11 @@ describe('ProjectDeleter', function () { describe('archiveProject', function () { beforeEach(function () { - const archived = [new ObjectId(this.user._id)] - this.ProjectHelper.calculateArchivedArray.returns(archived) - - this.ProjectMock.expects('findOne') - .withArgs({ _id: this.project._id }) - .chain('exec') - .resolves(this.project) - this.ProjectMock.expects('updateOne') .withArgs( { _id: this.project._id }, { - $set: { archived }, + $addToSet: { archived: new ObjectId(this.user._id) }, $pull: { trashed: new ObjectId(this.user._id) }, } ) @@ -574,32 +572,15 @@ describe('ProjectDeleter', function () { ) this.ProjectMock.verify() }) - - it('calculates the archived array', async function () { - await this.ProjectDeleter.promises.archiveProject( - this.project._id, - this.user._id - ) - expect(this.ProjectHelper.calculateArchivedArray).to.have.been.calledWith( - this.project, - this.user._id, - 'ARCHIVE' - ) - }) }) describe('unarchiveProject', function () { beforeEach(function () { - const archived = [new ObjectId(this.user._id)] - this.ProjectHelper.calculateArchivedArray.returns(archived) - - this.ProjectMock.expects('findOne') - .withArgs({ _id: this.project._id }) - .chain('exec') - .resolves(this.project) - this.ProjectMock.expects('updateOne') - .withArgs({ _id: this.project._id }, { $set: { archived } }) + .withArgs( + { _id: this.project._id }, + { $pull: { archived: new ObjectId(this.user._id) } } + ) .resolves() }) @@ -610,36 +591,16 @@ describe('ProjectDeleter', function () { ) this.ProjectMock.verify() }) - - it('calculates the archived array', async function () { - await this.ProjectDeleter.promises.unarchiveProject( - this.project._id, - this.user._id - ) - expect(this.ProjectHelper.calculateArchivedArray).to.have.been.calledWith( - this.project, - this.user._id, - 'UNARCHIVE' - ) - }) }) describe('trashProject', function () { beforeEach(function () { - const archived = [new ObjectId(this.user._id)] - this.ProjectHelper.calculateArchivedArray.returns(archived) - - this.ProjectMock.expects('findOne') - .withArgs({ _id: this.project._id }) - .chain('exec') - .resolves(this.project) - this.ProjectMock.expects('updateOne') .withArgs( { _id: this.project._id }, { $addToSet: { trashed: new ObjectId(this.user._id) }, - $set: { archived }, + $pull: { archived: new ObjectId(this.user._id) }, } ) .resolves() @@ -652,27 +613,10 @@ describe('ProjectDeleter', function () { ) this.ProjectMock.verify() }) - - it('unarchives the project', async function () { - await this.ProjectDeleter.promises.trashProject( - this.project._id, - this.user._id - ) - expect(this.ProjectHelper.calculateArchivedArray).to.have.been.calledWith( - this.project, - this.user._id, - 'UNARCHIVE' - ) - }) }) describe('untrashProject', function () { beforeEach(function () { - this.ProjectMock.expects('findOne') - .withArgs({ _id: this.project._id }) - .chain('exec') - .resolves(this.project) - this.ProjectMock.expects('updateOne') .withArgs( { _id: this.project._id }, diff --git a/services/web/test/unit/src/Project/ProjectHelperTests.js b/services/web/test/unit/src/Project/ProjectHelperTests.js index 7dc2343313..9aee9fc363 100644 --- a/services/web/test/unit/src/Project/ProjectHelperTests.js +++ b/services/web/test/unit/src/Project/ProjectHelperTests.js @@ -101,134 +101,6 @@ describe('ProjectHelper', function () { }) }) - describe('calculateArchivedArray', function () { - describe('project.archived being an array', function () { - it('returns an array adding the current user id when archiving', function () { - const project = { archived: [] } - const result = this.ProjectHelper.calculateArchivedArray( - project, - new ObjectId('5c922599cdb09e014aa7d499'), - 'ARCHIVE' - ) - expect(result).to.deep.equal([new ObjectId('5c922599cdb09e014aa7d499')]) - }) - - it('returns an array without the current user id when unarchiving', function () { - const project = { archived: [new ObjectId('5c922599cdb09e014aa7d499')] } - const result = this.ProjectHelper.calculateArchivedArray( - project, - new ObjectId('5c922599cdb09e014aa7d499'), - 'UNARCHIVE' - ) - expect(result).to.deep.equal([]) - }) - }) - - describe('project.archived being a boolean and being true', function () { - it('returns an array of all associated user ids when archiving', function () { - const project = { - archived: true, - owner_ref: this.user._id, - collaberator_refs: [ - new ObjectId('4f2cfb341eb5855a5b000f8b'), - new ObjectId('5c45f3bd425ead01488675aa'), - ], - readOnly_refs: [new ObjectId('5c92243fcdb09e014aa7d487')], - tokenAccessReadAndWrite_refs: [ - new ObjectId('5c922599cdb09e014aa7d499'), - ], - tokenAccessReadOnly_refs: [], - } - - const result = this.ProjectHelper.calculateArchivedArray( - project, - this.user._id, - 'ARCHIVE' - ) - expect(result).to.deep.equal([ - this.user._id, - new ObjectId('4f2cfb341eb5855a5b000f8b'), - new ObjectId('5c45f3bd425ead01488675aa'), - new ObjectId('5c92243fcdb09e014aa7d487'), - new ObjectId('5c922599cdb09e014aa7d499'), - ]) - }) - - it('returns an array of all associated users without the current user id when unarchived', function () { - const project = { - archived: true, - owner_ref: this.user._id, - collaberator_refs: [ - new ObjectId('4f2cfb341eb5855a5b000f8b'), - new ObjectId('5c45f3bd425ead01488675aa'), - new ObjectId('5c922599cdb09e014aa7d499'), - ], - readOnly_refs: [new ObjectId('5c92243fcdb09e014aa7d487')], - tokenAccessReadAndWrite_refs: [ - new ObjectId('5c922599cdb09e014aa7d499'), - ], - tokenAccessReadOnly_refs: [], - } - - const result = this.ProjectHelper.calculateArchivedArray( - project, - this.user._id, - 'UNARCHIVE' - ) - expect(result).to.deep.equal([ - new ObjectId('4f2cfb341eb5855a5b000f8b'), - new ObjectId('5c45f3bd425ead01488675aa'), - new ObjectId('5c922599cdb09e014aa7d499'), - new ObjectId('5c92243fcdb09e014aa7d487'), - ]) - }) - }) - - describe('project.archived being a boolean and being false', function () { - it('returns an array adding the current user id when archiving', function () { - const project = { archived: false } - const result = this.ProjectHelper.calculateArchivedArray( - project, - new ObjectId('5c922599cdb09e014aa7d499'), - 'ARCHIVE' - ) - expect(result).to.deep.equal([new ObjectId('5c922599cdb09e014aa7d499')]) - }) - - it('returns an empty array when unarchiving', function () { - const project = { archived: false } - const result = this.ProjectHelper.calculateArchivedArray( - project, - new ObjectId('5c922599cdb09e014aa7d499'), - 'UNARCHIVE' - ) - expect(result).to.deep.equal([]) - }) - }) - - describe('project.archived not being set', function () { - it('returns an array adding the current user id when archiving', function () { - const project = { archived: undefined } - const result = this.ProjectHelper.calculateArchivedArray( - project, - new ObjectId('5c922599cdb09e014aa7d499'), - 'ARCHIVE' - ) - expect(result).to.deep.equal([new ObjectId('5c922599cdb09e014aa7d499')]) - }) - - it('returns an empty array when unarchiving', function () { - const project = { archived: undefined } - const result = this.ProjectHelper.calculateArchivedArray( - project, - new ObjectId('5c922599cdb09e014aa7d499'), - 'UNARCHIVE' - ) - expect(result).to.deep.equal([]) - }) - }) - }) - describe('compilerFromV1Engine', function () { it('returns the correct engine for latex_dvipdf', function () { expect(this.ProjectHelper.compilerFromV1Engine('latex_dvipdf')).to.equal( diff --git a/services/web/test/unit/src/Project/ProjectListController.test.mjs b/services/web/test/unit/src/Project/ProjectListController.test.mjs index ae1bc72210..1f83bfb0c5 100644 --- a/services/web/test/unit/src/Project/ProjectListController.test.mjs +++ b/services/web/test/unit/src/Project/ProjectListController.test.mjs @@ -87,6 +87,7 @@ describe('ProjectListController', function () { promises: { getUsers: sinon.stub().resolves(ctx.usersArr), getUserFullEmails: sinon.stub().resolves([]), + getWritefullData: sinon.stub().resolves({ isPremium: true }), }, } ctx.Features = { @@ -145,6 +146,18 @@ describe('ProjectListController', function () { }, } + ctx.PermissionsManager = { + promises: { + checkUserPermissions: sinon.stub().resolves(true), + }, + } + + ctx.SubscriptionLocator = { + promises: { + getUsersSubscription: sinon.stub().resolves({}), + }, + } + vi.doMock('mongodb-legacy', () => ({ default: { ObjectId }, })) @@ -250,6 +263,19 @@ describe('ProjectListController', function () { default: ctx.TutorialHandler, })) + vi.doMock( + '../../../../app/src/Features/Authorization/PermissionsManager', + () => ({ + default: ctx.PermissionsManager, + }) + ) + vi.doMock( + '../../../../app/src/Features/Subscription/SubscriptionLocator', + () => ({ + default: ctx.SubscriptionLocator, + }) + ) + ctx.ProjectListController = (await import(MODULE_PATH)).default ctx.req = { @@ -297,8 +323,8 @@ describe('ProjectListController', function () { ctx.ProjectGetter.promises.findAllUsersProjects.resolves(ctx.allProjects) }) - it('should render the project/list-react page', function (ctx) { - return new Promise(resolve => { + it('should render the project/list-react page', async function (ctx) { + await new Promise(resolve => { ctx.res.render = (pageName, opts) => { pageName.should.equal('project/list-react') resolve() @@ -307,8 +333,8 @@ describe('ProjectListController', function () { }) }) - it('should invoke the session maintenance', function (ctx) { - return new Promise(resolve => { + it('should invoke the session maintenance', async function (ctx) { + await new Promise(resolve => { ctx.Features.hasFeature.withArgs('saas').returns(true) ctx.res.render = () => { ctx.SplitTestSessionHandler.promises.sessionMaintenance.should.have.been.calledWith( @@ -321,8 +347,8 @@ describe('ProjectListController', function () { }) }) - it('should send the tags', function (ctx) { - return new Promise(resolve => { + it('should send the tags', async function (ctx) { + await new Promise(resolve => { ctx.res.render = (pageName, opts) => { opts.tags.length.should.equal(ctx.tags.length) resolve() @@ -331,8 +357,8 @@ describe('ProjectListController', function () { }) }) - it('should create trigger ip matcher notifications', function (ctx) { - return new Promise(resolve => { + it('should create trigger ip matcher notifications', async function (ctx) { + await new Promise(resolve => { ctx.settings.overleaf = true ctx.req.ip = '111.111.111.111' ctx.res.render = (pageName, opts) => { @@ -345,8 +371,8 @@ describe('ProjectListController', function () { }) }) - it('should send the projects', function (ctx) { - return new Promise(resolve => { + it('should send the projects', async function (ctx) { + await new Promise(resolve => { ctx.res.render = (pageName, opts) => { opts.prefetchedProjectsBlob.projects.length.should.equal( ctx.projects.length + @@ -362,8 +388,8 @@ describe('ProjectListController', function () { }) }) - it('should send the user', function (ctx) { - return new Promise(resolve => { + it('should send the user', async function (ctx) { + await new Promise(resolve => { ctx.res.render = (pageName, opts) => { opts.user.should.deep.equal(ctx.user) resolve() @@ -372,8 +398,8 @@ describe('ProjectListController', function () { }) }) - it('should inject the users', function (ctx) { - return new Promise(resolve => { + it('should inject the users', async function (ctx) { + await new Promise(resolve => { ctx.res.render = (pageName, opts) => { const projects = opts.prefetchedProjectsBlob.projects @@ -401,8 +427,8 @@ describe('ProjectListController', function () { }) }) - it("should send the user's best subscription when saas feature present", function (ctx) { - return new Promise(resolve => { + it("should send the user's best subscription when saas feature present", async function (ctx) { + await new Promise(resolve => { ctx.Features.hasFeature.withArgs('saas').returns(true) ctx.res.render = (pageName, opts) => { expect(opts.usersBestSubscription).to.deep.include({ type: 'free' }) @@ -412,8 +438,8 @@ describe('ProjectListController', function () { }) }) - it('should not return a best subscription without saas feature', function (ctx) { - return new Promise(resolve => { + it('should not return a best subscription without saas feature', async function (ctx) { + await new Promise(resolve => { ctx.Features.hasFeature.withArgs('saas').returns(false) ctx.res.render = (pageName, opts) => { expect(opts.usersBestSubscription).to.be.undefined @@ -423,8 +449,8 @@ describe('ProjectListController', function () { }) }) - it('should show INR Banner for Indian users with free account', function (ctx) { - return new Promise(resolve => { + it('should show INR Banner for Indian users with free account', async function (ctx) { + await new Promise(resolve => { // usersBestSubscription is only available when saas feature is present ctx.Features.hasFeature.withArgs('saas').returns(true) ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves( @@ -445,8 +471,8 @@ describe('ProjectListController', function () { }) }) - it('should not show INR Banner for Indian users with premium account', function (ctx) { - return new Promise(resolve => { + it('should not show INR Banner for Indian users with premium account', async function (ctx) { + await new Promise(resolve => { // usersBestSubscription is only available when saas feature is present ctx.Features.hasFeature.withArgs('saas').returns(true) ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves( @@ -468,8 +494,8 @@ describe('ProjectListController', function () { }) describe('With Institution SSO feature', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.institutionEmail = 'test@overleaf.com' ctx.institutionName = 'Overleaf' ctx.Features.hasFeature.withArgs('saml').returns(true) @@ -599,8 +625,8 @@ describe('ProjectListController', function () { }) describe('for an unconfirmed domain for an SSO institution', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.UserGetter.promises.getUserFullEmails.resolves([ { email: 'test@overleaf-uncofirmed.com', @@ -648,8 +674,8 @@ describe('ProjectListController', function () { }) }) describe('Institution with SSO beta testable', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.UserGetter.promises.getUserFullEmails.resolves([ { email: 'beta@beta.com', @@ -695,8 +721,8 @@ describe('ProjectListController', function () { }) describe('Without Institution SSO feature', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.Features.hasFeature.withArgs('saml').returns(false) resolve() }) @@ -834,8 +860,8 @@ describe('ProjectListController', function () { ctx.ProjectGetter.promises.findAllUsersProjects.resolves(ctx.allProjects) }) - it('should render the project/list-react page', function (ctx) { - return new Promise(resolve => { + it('should render the project/list-react page', async function (ctx) { + await new Promise(resolve => { ctx.res.render = (pageName, opts) => { pageName.should.equal('project/list-react') resolve() @@ -844,8 +870,8 @@ describe('ProjectListController', function () { }) }) - it('should omit one of the projects', function (ctx) { - return new Promise(resolve => { + it('should omit one of the projects', async function (ctx) { + await new Promise(resolve => { ctx.res.render = (pageName, opts) => { opts.prefetchedProjectsBlob.projects.length.should.equal( ctx.projects.length + diff --git a/services/web/test/unit/src/Referal/ReferalConnect.test.mjs b/services/web/test/unit/src/Referal/ReferalConnect.test.mjs index 33e6c6816e..c4b66228b2 100644 --- a/services/web/test/unit/src/Referal/ReferalConnect.test.mjs +++ b/services/web/test/unit/src/Referal/ReferalConnect.test.mjs @@ -8,8 +8,8 @@ describe('Referal connect middle wear', function () { ctx.connect = (await import(modulePath)).default }) - it('should take a referal query string and put it on the session if it exists', function (ctx) { - return new Promise(resolve => { + it('should take a referal query string and put it on the session if it exists', async function (ctx) { + await new Promise(resolve => { const req = { query: { referal: '12345' }, session: {}, @@ -21,8 +21,8 @@ describe('Referal connect middle wear', function () { }) }) - it('should not change the referal_id on the session if not in query', function (ctx) { - return new Promise(resolve => { + it('should not change the referal_id on the session if not in query', async function (ctx) { + await new Promise(resolve => { const req = { query: {}, session: { referal_id: 'same' }, @@ -34,8 +34,8 @@ describe('Referal connect middle wear', function () { }) }) - it('should take a facebook referal query string and put it on the session if it exists', function (ctx) { - return new Promise(resolve => { + it('should take a facebook referal query string and put it on the session if it exists', async function (ctx) { + await new Promise(resolve => { const req = { query: { fb_ref: '12345' }, session: {}, @@ -47,8 +47,8 @@ describe('Referal connect middle wear', function () { }) }) - it('should map the facebook medium into the session', function (ctx) { - return new Promise(resolve => { + it('should map the facebook medium into the session', async function (ctx) { + await new Promise(resolve => { const req = { query: { rm: 'fb' }, session: {}, @@ -60,8 +60,8 @@ describe('Referal connect middle wear', function () { }) }) - it('should map the twitter medium into the session', function (ctx) { - return new Promise(resolve => { + it('should map the twitter medium into the session', async function (ctx) { + await new Promise(resolve => { const req = { query: { rm: 't' }, session: {}, @@ -73,8 +73,8 @@ describe('Referal connect middle wear', function () { }) }) - it('should map the google plus medium into the session', function (ctx) { - return new Promise(resolve => { + it('should map the google plus medium into the session', async function (ctx) { + await new Promise(resolve => { const req = { query: { rm: 'gp' }, session: {}, @@ -86,8 +86,8 @@ describe('Referal connect middle wear', function () { }) }) - it('should map the email medium into the session', function (ctx) { - return new Promise(resolve => { + it('should map the email medium into the session', async function (ctx) { + await new Promise(resolve => { const req = { query: { rm: 'e' }, session: {}, @@ -99,8 +99,8 @@ describe('Referal connect middle wear', function () { }) }) - it('should map the direct medium into the session', function (ctx) { - return new Promise(resolve => { + it('should map the direct medium into the session', async function (ctx) { + await new Promise(resolve => { const req = { query: { rm: 'd' }, session: {}, @@ -112,8 +112,8 @@ describe('Referal connect middle wear', function () { }) }) - it('should map the bonus source into the session', function (ctx) { - return new Promise(resolve => { + it('should map the bonus source into the session', async function (ctx) { + await new Promise(resolve => { const req = { query: { rs: 'b' }, session: {}, @@ -125,8 +125,8 @@ describe('Referal connect middle wear', function () { }) }) - it('should map the public share source into the session', function (ctx) { - return new Promise(resolve => { + it('should map the public share source into the session', async function (ctx) { + await new Promise(resolve => { const req = { query: { rs: 'ps' }, session: {}, @@ -138,8 +138,8 @@ describe('Referal connect middle wear', function () { }) }) - it('should map the collaborator invite into the session', function (ctx) { - return new Promise(resolve => { + it('should map the collaborator invite into the session', async function (ctx) { + await new Promise(resolve => { const req = { query: { rs: 'ci' }, session: {}, diff --git a/services/web/test/unit/src/References/ReferencesController.test.mjs b/services/web/test/unit/src/References/ReferencesController.test.mjs index 679e835840..c578712f45 100644 --- a/services/web/test/unit/src/References/ReferencesController.test.mjs +++ b/services/web/test/unit/src/References/ReferencesController.test.mjs @@ -61,8 +61,8 @@ describe('ReferencesController', function () { } }) - it('should not produce an error', function (ctx) { - return new Promise(resolve => { + it('should not produce an error', async function (ctx) { + await new Promise(resolve => { ctx.call(() => { ctx.res.sendStatus.callCount.should.equal(0) ctx.res.sendStatus.calledWith(500).should.equal(false) @@ -72,8 +72,8 @@ describe('ReferencesController', function () { }) }) - it('should return data', function (ctx) { - return new Promise(resolve => { + it('should return data', async function (ctx) { + await new Promise(resolve => { ctx.call(() => { ctx.res.json.callCount.should.equal(1) ctx.res.json.calledWith(ctx.fakeResponseData).should.equal(true) @@ -82,8 +82,8 @@ describe('ReferencesController', function () { }) }) - it('should call ReferencesHandler.indexAll', function (ctx) { - return new Promise(resolve => { + it('should call ReferencesHandler.indexAll', async function (ctx) { + await new Promise(resolve => { ctx.call(() => { ctx.ReferencesHandler.indexAll.callCount.should.equal(1) ctx.ReferencesHandler.indexAll @@ -100,8 +100,8 @@ describe('ReferencesController', function () { ctx.req.body.shouldBroadcast = true }) - it('should call EditorRealTimeController.emitToRoom', function (ctx) { - return new Promise(resolve => { + it('should call EditorRealTimeController.emitToRoom', async function (ctx) { + await new Promise(resolve => { ctx.call(() => { ctx.EditorRealTimeController.emitToRoom.callCount.should.equal(1) resolve() @@ -109,8 +109,8 @@ describe('ReferencesController', function () { }) }) - it('should not produce an error', function (ctx) { - return new Promise(resolve => { + it('should not produce an error', async function (ctx) { + await new Promise(resolve => { ctx.call(() => { ctx.res.sendStatus.callCount.should.equal(0) ctx.res.sendStatus.calledWith(500).should.equal(false) @@ -120,8 +120,8 @@ describe('ReferencesController', function () { }) }) - it('should still return data', function (ctx) { - return new Promise(resolve => { + it('should still return data', async function (ctx) { + await new Promise(resolve => { ctx.call(() => { ctx.res.json.callCount.should.equal(1) ctx.res.json.calledWith(ctx.fakeResponseData).should.equal(true) @@ -137,8 +137,8 @@ describe('ReferencesController', function () { ctx.req.body.shouldBroadcast = false }) - it('should not call EditorRealTimeController.emitToRoom', function (ctx) { - return new Promise(resolve => { + it('should not call EditorRealTimeController.emitToRoom', async function (ctx) { + await new Promise(resolve => { ctx.call(() => { ctx.EditorRealTimeController.emitToRoom.callCount.should.equal(0) resolve() @@ -146,8 +146,8 @@ describe('ReferencesController', function () { }) }) - it('should not produce an error', function (ctx) { - return new Promise(resolve => { + it('should not produce an error', async function (ctx) { + await new Promise(resolve => { ctx.call(() => { ctx.res.sendStatus.callCount.should.equal(0) ctx.res.sendStatus.calledWith(500).should.equal(false) @@ -157,8 +157,8 @@ describe('ReferencesController', function () { }) }) - it('should still return data', function (ctx) { - return new Promise(resolve => { + it('should still return data', async function (ctx) { + await new Promise(resolve => { ctx.call(() => { ctx.res.json.callCount.should.equal(1) ctx.res.json.calledWith(ctx.fakeResponseData).should.equal(true) @@ -178,8 +178,8 @@ describe('ReferencesController', function () { } }) - it('should not call EditorRealTimeController.emitToRoom', function (ctx) { - return new Promise(resolve => { + it('should not call EditorRealTimeController.emitToRoom', async function (ctx) { + await new Promise(resolve => { ctx.call(() => { ctx.EditorRealTimeController.emitToRoom.callCount.should.equal(0) resolve() @@ -187,8 +187,8 @@ describe('ReferencesController', function () { }) }) - it('should not produce an error', function (ctx) { - return new Promise(resolve => { + it('should not produce an error', async function (ctx) { + await new Promise(resolve => { ctx.call(() => { ctx.res.sendStatus.callCount.should.equal(0) ctx.res.sendStatus.calledWith(500).should.equal(false) @@ -198,8 +198,8 @@ describe('ReferencesController', function () { }) }) - it('should send a response with an empty keys list', function (ctx) { - return new Promise(resolve => { + it('should send a response with an empty keys list', async function (ctx) { + await new Promise(resolve => { ctx.call(() => { ctx.res.json.called.should.equal(true) ctx.res.json diff --git a/services/web/test/unit/src/References/ReferencesHandler.test.mjs b/services/web/test/unit/src/References/ReferencesHandler.test.mjs index 92666e6bcc..1b5d2c1ba0 100644 --- a/services/web/test/unit/src/References/ReferencesHandler.test.mjs +++ b/services/web/test/unit/src/References/ReferencesHandler.test.mjs @@ -113,8 +113,8 @@ describe('ReferencesHandler', function () { }) }) - it('should call _findBibDocIds', function (ctx) { - return new Promise(resolve => { + it('should call _findBibDocIds', async function (ctx) { + await new Promise(resolve => { return ctx.call((err, data) => { expect(err).to.be.null ctx.handler._findBibDocIds.callCount.should.equal(1) @@ -126,8 +126,8 @@ describe('ReferencesHandler', function () { }) }) - it('should call _findBibFileRefs', function (ctx) { - return new Promise(resolve => { + it('should call _findBibFileRefs', async function (ctx) { + await new Promise(resolve => { return ctx.call((err, data) => { expect(err).to.be.null ctx.handler._findBibDocIds.callCount.should.equal(1) @@ -139,8 +139,8 @@ describe('ReferencesHandler', function () { }) }) - it('should call DocumentUpdaterHandler.flushDocToMongo', function (ctx) { - return new Promise(resolve => { + it('should call DocumentUpdaterHandler.flushDocToMongo', async function (ctx) { + await new Promise(resolve => { return ctx.call((err, data) => { expect(err).to.be.null ctx.DocumentUpdaterHandler.flushDocToMongo.callCount.should.equal(2) @@ -149,8 +149,8 @@ describe('ReferencesHandler', function () { }) }) - it('should make a request to references service', function (ctx) { - return new Promise(resolve => { + it('should make a request to references service', async function (ctx) { + await new Promise(resolve => { return ctx.call((err, data) => { expect(err).to.be.null ctx.request.post.callCount.should.equal(1) @@ -189,8 +189,8 @@ describe('ReferencesHandler', function () { }) }) - it('should not produce an error', function (ctx) { - return new Promise(resolve => { + it('should not produce an error', async function (ctx) { + await new Promise(resolve => { return ctx.call((err, data) => { expect(err).to.equal(null) return resolve() @@ -198,8 +198,8 @@ describe('ReferencesHandler', function () { }) }) - it('should return data', function (ctx) { - return new Promise(resolve => { + it('should return data', async function (ctx) { + await new Promise(resolve => { return ctx.call((err, data) => { expect(err).to.be.null expect(data).to.not.equal(null) @@ -215,8 +215,8 @@ describe('ReferencesHandler', function () { ctx.ProjectGetter.getProject.callsArgWith(2, new Error('woops')) }) - it('should produce an error', function (ctx) { - return new Promise(resolve => { + it('should produce an error', async function (ctx) { + await new Promise(resolve => { ctx.call((err, data) => { expect(err).to.not.equal(null) expect(err).to.be.instanceof(Error) @@ -226,8 +226,8 @@ describe('ReferencesHandler', function () { }) }) - it('should not send request', function (ctx) { - return new Promise(resolve => { + it('should not send request', async function (ctx) { + await new Promise(resolve => { ctx.call(() => { ctx.request.post.callCount.should.equal(0) resolve() @@ -241,8 +241,8 @@ describe('ReferencesHandler', function () { ctx.ProjectGetter.getProject.callsArgWith(2, null) }) - it('should produce an error', function (ctx) { - return new Promise(resolve => { + it('should produce an error', async function (ctx) { + await new Promise(resolve => { ctx.call((err, data) => { expect(err).to.not.equal(null) expect(err).to.be.instanceof(Errors.NotFoundError) @@ -252,8 +252,8 @@ describe('ReferencesHandler', function () { }) }) - it('should not send request', function (ctx) { - return new Promise(resolve => { + it('should not send request', async function (ctx) { + await new Promise(resolve => { ctx.call(() => { ctx.request.post.callCount.should.equal(0) resolve() @@ -268,8 +268,8 @@ describe('ReferencesHandler', function () { ctx.handler._isFullIndex.callsArgWith(1, new Error('woops')) }) - it('should produce an error', function (ctx) { - return new Promise(resolve => { + it('should produce an error', async function (ctx) { + await new Promise(resolve => { ctx.call((err, data) => { expect(err).to.not.equal(null) expect(err).to.be.instanceof(Error) @@ -279,8 +279,8 @@ describe('ReferencesHandler', function () { }) }) - it('should not send request', function (ctx) { - return new Promise(resolve => { + it('should not send request', async function (ctx) { + await new Promise(resolve => { ctx.call(() => { ctx.request.post.callCount.should.equal(0) resolve() @@ -299,8 +299,8 @@ describe('ReferencesHandler', function () { ) }) - it('should produce an error', function (ctx) { - return new Promise(resolve => { + it('should produce an error', async function (ctx) { + await new Promise(resolve => { ctx.call((err, data) => { expect(err).to.not.equal(null) expect(err).to.be.instanceof(Error) @@ -310,8 +310,8 @@ describe('ReferencesHandler', function () { }) }) - it('should not send request', function (ctx) { - return new Promise(resolve => { + it('should not send request', async function (ctx) { + await new Promise(resolve => { ctx.call(() => { ctx.request.post.callCount.should.equal(0) resolve() diff --git a/services/web/test/unit/src/Subscription/FeaturesUpdaterTests.js b/services/web/test/unit/src/Subscription/FeaturesUpdaterTests.js index 8cdf313395..dccbff476c 100644 --- a/services/web/test/unit/src/Subscription/FeaturesUpdaterTests.js +++ b/services/web/test/unit/src/Subscription/FeaturesUpdaterTests.js @@ -4,7 +4,7 @@ const sinon = require('sinon') const { ObjectId } = require('mongodb-legacy') const { AI_ADD_ON_CODE, -} = require('../../../../app/src/Features/Subscription/PaymentProviderEntities') +} = require('../../../../app/src/Features/Subscription/AiHelper') const MODULE_PATH = '../../../../app/src/Features/Subscription/FeaturesUpdater' diff --git a/services/web/test/unit/src/Subscription/PaymentProviderEntitiesTest.js b/services/web/test/unit/src/Subscription/PaymentProviderEntitiesTest.js index 07c401dfb8..7b2b85b4d8 100644 --- a/services/web/test/unit/src/Subscription/PaymentProviderEntitiesTest.js +++ b/services/web/test/unit/src/Subscription/PaymentProviderEntitiesTest.js @@ -4,13 +4,15 @@ const SandboxedModule = require('sandboxed-module') const { expect } = require('chai') const Errors = require('../../../../app/src/Features/Subscription/Errors') const { - AI_ADD_ON_CODE, PaymentProviderSubscriptionChangeRequest, PaymentProviderSubscriptionUpdateRequest, PaymentProviderSubscriptionChange, PaymentProviderSubscription, PaymentProviderSubscriptionAddOnUpdate, } = require('../../../../app/src/Features/Subscription/PaymentProviderEntities') +const { + AI_ADD_ON_CODE, +} = require('../../../../app/src/Features/Subscription/AiHelper') const SubscriptionHelper = require('../../../../app/src/Features/Subscription/SubscriptionHelper') const MODULE_PATH = @@ -25,6 +27,10 @@ describe('PaymentProviderEntities', function () { { planCode: 'cheap-plan', price_in_cents: 500 }, { planCode: 'regular-plan', price_in_cents: 1000 }, { planCode: 'premium-plan', price_in_cents: 2000 }, + { + planCode: 'group_collaborator_10_enterprise', + price_in_cents: 10000, + }, ], features: [], } @@ -79,8 +85,11 @@ describe('PaymentProviderEntities', function () { it('returns a change request for upgrades', function () { const { PaymentProviderSubscriptionChangeRequest } = this.PaymentProviderEntities - const changeRequest = - this.subscription.getRequestForPlanChange('premium-plan') + const changeRequest = this.subscription.getRequestForPlanChange( + 'premium-plan', + 1, + this.subscription.shouldPlanChangeAtTermEnd('premium-plan') + ) expect(changeRequest).to.deep.equal( new PaymentProviderSubscriptionChangeRequest({ subscription: this.subscription, @@ -93,8 +102,11 @@ describe('PaymentProviderEntities', function () { it('returns a change request for downgrades', function () { const { PaymentProviderSubscriptionChangeRequest } = this.PaymentProviderEntities - const changeRequest = - this.subscription.getRequestForPlanChange('cheap-plan') + const changeRequest = this.subscription.getRequestForPlanChange( + 'cheap-plan', + 1, + this.subscription.shouldPlanChangeAtTermEnd('cheap-plan') + ) expect(changeRequest).to.deep.equal( new PaymentProviderSubscriptionChangeRequest({ subscription: this.subscription, @@ -110,8 +122,11 @@ describe('PaymentProviderEntities', function () { this.subscription.trialPeriodEnd = fiveDaysFromNow const { PaymentProviderSubscriptionChangeRequest } = this.PaymentProviderEntities - const changeRequest = - this.subscription.getRequestForPlanChange('cheap-plan') + const changeRequest = this.subscription.getRequestForPlanChange( + 'cheap-plan', + 1, + this.subscription.shouldPlanChangeAtTermEnd('cheap-plan') + ) expect(changeRequest).to.deep.equal( new PaymentProviderSubscriptionChangeRequest({ subscription: this.subscription, @@ -125,8 +140,11 @@ describe('PaymentProviderEntities', function () { const { PaymentProviderSubscriptionChangeRequest } = this.PaymentProviderEntities this.addOn.code = AI_ADD_ON_CODE - const changeRequest = - this.subscription.getRequestForPlanChange('premium-plan') + const changeRequest = this.subscription.getRequestForPlanChange( + 'premium-plan', + 1, + this.subscription.shouldPlanChangeAtTermEnd('premium-plan') + ) expect(changeRequest).to.deep.equal( new PaymentProviderSubscriptionChangeRequest({ subscription: this.subscription, @@ -146,8 +164,11 @@ describe('PaymentProviderEntities', function () { const { PaymentProviderSubscriptionChangeRequest } = this.PaymentProviderEntities this.addOn.code = AI_ADD_ON_CODE - const changeRequest = - this.subscription.getRequestForPlanChange('cheap-plan') + const changeRequest = this.subscription.getRequestForPlanChange( + 'cheap-plan', + 1, + this.subscription.shouldPlanChangeAtTermEnd('cheap-plan') + ) expect(changeRequest).to.deep.equal( new PaymentProviderSubscriptionChangeRequest({ subscription: this.subscription, @@ -168,8 +189,11 @@ describe('PaymentProviderEntities', function () { this.PaymentProviderEntities this.subscription.planCode = 'assistant-annual' this.subscription.addOns = [] - const changeRequest = - this.subscription.getRequestForPlanChange('cheap-plan') + const changeRequest = this.subscription.getRequestForPlanChange( + 'cheap-plan', + 1, + this.subscription.shouldPlanChangeAtTermEnd('cheap-plan') + ) expect(changeRequest).to.deep.equal( new PaymentProviderSubscriptionChangeRequest({ subscription: this.subscription, @@ -184,6 +208,63 @@ describe('PaymentProviderEntities', function () { }) ) }) + + it('upgrade from individual to group plan for Stripe subscription', function () { + this.subscription.service = 'stripe-uk' + const { PaymentProviderSubscriptionChangeRequest } = + this.PaymentProviderEntities + const changeRequest = this.subscription.getRequestForPlanChange( + 'group_collaborator', + 10, + this.subscription.shouldPlanChangeAtTermEnd( + 'group_collaborator_10_enterprise' + ) + ) + expect(changeRequest).to.deep.equal( + new PaymentProviderSubscriptionChangeRequest({ + subscription: this.subscription, + timeframe: 'now', + planCode: 'group_collaborator', + addOnUpdates: [ + new PaymentProviderSubscriptionAddOnUpdate({ + code: 'additional-license', + quantity: 10, + }), + ], + }) + ) + }) + + it('upgrade from individual to group plan and preserves the AI add-on for Stripe subscription', function () { + this.subscription.service = 'stripe-uk' + const { PaymentProviderSubscriptionChangeRequest } = + this.PaymentProviderEntities + this.addOn.code = AI_ADD_ON_CODE + const changeRequest = this.subscription.getRequestForPlanChange( + 'group_collaborator', + 10, + this.subscription.shouldPlanChangeAtTermEnd( + 'group_collaborator_10_enterprise' + ) + ) + expect(changeRequest).to.deep.equal( + new PaymentProviderSubscriptionChangeRequest({ + subscription: this.subscription, + timeframe: 'now', + planCode: 'group_collaborator', + addOnUpdates: [ + new PaymentProviderSubscriptionAddOnUpdate({ + code: 'additional-license', + quantity: 10, + }), + new PaymentProviderSubscriptionAddOnUpdate({ + code: AI_ADD_ON_CODE, + quantity: 1, + }), + ], + }) + ) + }) }) describe('getRequestForAddOnPurchase()', function () { diff --git a/services/web/test/unit/src/Subscription/PlansLocatorTests.js b/services/web/test/unit/src/Subscription/PlansLocatorTests.js index bd15f5cfaa..7e35ccea58 100644 --- a/services/web/test/unit/src/Subscription/PlansLocatorTests.js +++ b/services/web/test/unit/src/Subscription/PlansLocatorTests.js @@ -266,4 +266,39 @@ describe('PlansLocator', function () { expect(period).to.equal('annual') }) }) + + describe('convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded', function () { + it('returns original plan name for non-group plan codes', function () { + expect( + this.PlansLocator.convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded( + 'professional' + ) + ).to.deep.equal({ + planCode: 'professional', + quantity: 1, + }) + }) + + it('converts Recurly enterprise group plan codes to Stripe group plan codes', function () { + expect( + this.PlansLocator.convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded( + 'group_collaborator_10_enterprise' + ) + ).to.deep.equal({ + planCode: 'group_collaborator', + quantity: 10, + }) + }) + + it('converts Recurly educational group plan codes to Stripe group plan codes', function () { + expect( + this.PlansLocator.convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded( + 'group_professional_10_educational' + ) + ).to.deep.equal({ + planCode: 'group_professional_educational', + quantity: 10, + }) + }) + }) }) diff --git a/services/web/test/unit/src/Subscription/RecurlyEventHandlerTests.js b/services/web/test/unit/src/Subscription/RecurlyEventHandlerTests.js index 2528f0a451..5620d0f106 100644 --- a/services/web/test/unit/src/Subscription/RecurlyEventHandlerTests.js +++ b/services/web/test/unit/src/Subscription/RecurlyEventHandlerTests.js @@ -41,14 +41,15 @@ describe('RecurlyEventHandler', function () { getAssignmentForUser: sinon.stub().resolves({ variant: 'default', }), + hasUserBeenAssignedToVariant: sinon.stub().resolves(false), }, }), }, }) }) - it('with new_subscription_notification - free trial', function () { - this.RecurlyEventHandler.sendRecurlyAnalyticsEvent( + it('with new_subscription_notification - free trial', async function () { + await this.RecurlyEventHandler.sendRecurlyAnalyticsEvent( 'new_subscription_notification', this.eventData ) @@ -63,6 +64,48 @@ describe('RecurlyEventHandler', function () { has_ai_add_on: false, subscriptionId: this.eventData.subscription.uuid, payment_provider: 'recurly', + 'customerio-integration': false, + } + ) + sinon.assert.calledWith( + this.AnalyticsManager.setUserPropertyForUserInBackground, + this.userId, + 'subscription-plan-code', + this.planCode + ) + sinon.assert.calledWith( + this.AnalyticsManager.setUserPropertyForUserInBackground, + this.userId, + 'subscription-state', + 'active' + ) + sinon.assert.calledWith( + this.AnalyticsManager.setUserPropertyForUserInBackground, + this.userId, + 'subscription-is-trial', + true + ) + }) + + it('with new_subscription_notification - free trial with customerio integration enabled', async function () { + this.SplitTestHandler.promises.hasUserBeenAssignedToVariant.resolves(true) + + await this.RecurlyEventHandler.sendRecurlyAnalyticsEvent( + 'new_subscription_notification', + this.eventData + ) + sinon.assert.calledWith( + this.AnalyticsManager.recordEventForUserInBackground, + this.userId, + 'subscription-started', + { + plan_code: this.planCode, + quantity: 1, + is_trial: true, + has_ai_add_on: false, + subscriptionId: this.eventData.subscription.uuid, + payment_provider: 'recurly', + 'customerio-integration': true, } ) sinon.assert.calledWith( @@ -94,7 +137,7 @@ describe('RecurlyEventHandler', function () { sinon.assert.called(this.SubscriptionEmailHandler.sendTrialOnboardingEmail) }) - it('with new_subscription_notification - no free trial', function () { + it('with new_subscription_notification - no free trial', async function () { this.eventData.subscription.current_period_started_at = new Date( '2021-02-10 12:34:56' ) @@ -103,7 +146,7 @@ describe('RecurlyEventHandler', function () { ) this.eventData.subscription.quantity = 3 - this.RecurlyEventHandler.sendRecurlyAnalyticsEvent( + await this.RecurlyEventHandler.sendRecurlyAnalyticsEvent( 'new_subscription_notification', this.eventData ) @@ -118,6 +161,7 @@ describe('RecurlyEventHandler', function () { has_ai_add_on: false, subscriptionId: this.eventData.subscription.uuid, payment_provider: 'recurly', + 'customerio-integration': false, } ) sinon.assert.calledWith( @@ -134,10 +178,10 @@ describe('RecurlyEventHandler', function () { ) }) - it('with updated_subscription_notification', function () { + it('with updated_subscription_notification', async function () { this.planCode = 'new-plan-code' this.eventData.subscription.plan.plan_code = this.planCode - this.RecurlyEventHandler.sendRecurlyAnalyticsEvent( + await this.RecurlyEventHandler.sendRecurlyAnalyticsEvent( 'updated_subscription_notification', this.eventData ) @@ -152,6 +196,50 @@ describe('RecurlyEventHandler', function () { has_ai_add_on: false, subscriptionId: this.eventData.subscription.uuid, payment_provider: 'recurly', + 'customerio-integration': false, + } + ) + sinon.assert.calledWith( + this.AnalyticsManager.setUserPropertyForUserInBackground, + this.userId, + 'subscription-plan-code', + this.planCode + ) + sinon.assert.calledWith( + this.AnalyticsManager.setUserPropertyForUserInBackground, + this.userId, + 'subscription-state', + 'active' + ) + sinon.assert.calledWith( + this.AnalyticsManager.setUserPropertyForUserInBackground, + this.userId, + 'subscription-is-trial', + true + ) + }) + + it('with updated_subscription_notification with customerio integration enabled', async function () { + this.SplitTestHandler.promises.hasUserBeenAssignedToVariant.resolves(true) + this.planCode = 'new-plan-code' + this.eventData.subscription.plan.plan_code = this.planCode + + await this.RecurlyEventHandler.sendRecurlyAnalyticsEvent( + 'updated_subscription_notification', + this.eventData + ) + sinon.assert.calledWith( + this.AnalyticsManager.recordEventForUserInBackground, + this.userId, + 'subscription-updated', + { + plan_code: this.planCode, + quantity: 1, + is_trial: true, + has_ai_add_on: false, + subscriptionId: this.eventData.subscription.uuid, + payment_provider: 'recurly', + 'customerio-integration': true, } ) sinon.assert.calledWith( @@ -191,6 +279,7 @@ describe('RecurlyEventHandler', function () { has_ai_add_on: false, subscriptionId: this.eventData.subscription.uuid, payment_provider: 'recurly', + 'customerio-integration': false, } ) sinon.assert.calledWith( @@ -207,9 +296,9 @@ describe('RecurlyEventHandler', function () { ) }) - it('with expired_subscription_notification', function () { + it('with expired_subscription_notification', async function () { this.eventData.subscription.state = 'expired' - this.RecurlyEventHandler.sendRecurlyAnalyticsEvent( + await this.RecurlyEventHandler.sendRecurlyAnalyticsEvent( 'expired_subscription_notification', this.eventData ) @@ -224,6 +313,7 @@ describe('RecurlyEventHandler', function () { has_ai_add_on: false, subscriptionId: this.eventData.subscription.uuid, payment_provider: 'recurly', + 'customerio-integration': false, } ) sinon.assert.calledWith( @@ -246,8 +336,8 @@ describe('RecurlyEventHandler', function () { ) }) - it('with renewed_subscription_notification', function () { - this.RecurlyEventHandler.sendRecurlyAnalyticsEvent( + it('with renewed_subscription_notification', async function () { + await this.RecurlyEventHandler.sendRecurlyAnalyticsEvent( 'renewed_subscription_notification', this.eventData ) @@ -262,12 +352,13 @@ describe('RecurlyEventHandler', function () { has_ai_add_on: false, subscriptionId: this.eventData.subscription.uuid, payment_provider: 'recurly', + 'customerio-integration': false, } ) }) - it('with reactivated_account_notification', function () { - this.RecurlyEventHandler.sendRecurlyAnalyticsEvent( + it('with reactivated_account_notification', async function () { + await this.RecurlyEventHandler.sendRecurlyAnalyticsEvent( 'reactivated_account_notification', this.eventData ) @@ -281,11 +372,12 @@ describe('RecurlyEventHandler', function () { has_ai_add_on: false, subscriptionId: this.eventData.subscription.uuid, payment_provider: 'recurly', + 'customerio-integration': false, } ) }) - it('with paid_charge_invoice_notification', function () { + it('with paid_charge_invoice_notification', async function () { const invoice = { invoice_number: 1234, currency: 'USD', @@ -298,7 +390,7 @@ describe('RecurlyEventHandler', function () { collection_method: 'automatic', subscription_ids: ['abcd1234', 'defa3214'], } - this.RecurlyEventHandler.sendRecurlyAnalyticsEvent( + await this.RecurlyEventHandler.sendRecurlyAnalyticsEvent( 'paid_charge_invoice_notification', { account: { @@ -325,8 +417,8 @@ describe('RecurlyEventHandler', function () { ) }) - it('with paid_charge_invoice_notification and total_in_cents 0', function () { - this.RecurlyEventHandler.sendRecurlyAnalyticsEvent( + it('with paid_charge_invoice_notification and total_in_cents 0', async function () { + await this.RecurlyEventHandler.sendRecurlyAnalyticsEvent( 'paid_charge_invoice_notification', { account: { @@ -341,8 +433,8 @@ describe('RecurlyEventHandler', function () { sinon.assert.notCalled(this.AnalyticsManager.recordEventForUserInBackground) }) - it('with closed_invoice_notification', function () { - this.RecurlyEventHandler.sendRecurlyAnalyticsEvent( + it('with closed_invoice_notification', async function () { + await this.RecurlyEventHandler.sendRecurlyAnalyticsEvent( 'closed_invoice_notification', { account: { diff --git a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js index 087df52815..24ce1bce8b 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js @@ -7,6 +7,9 @@ const modulePath = '../../../../app/src/Features/Subscription/SubscriptionController' const SubscriptionErrors = require('../../../../app/src/Features/Subscription/Errors') const SubscriptionHelper = require('../../../../app/src/Features/Subscription/SubscriptionHelper') +const { + AI_ADD_ON_CODE, +} = require('../../../../app/src/Features/Subscription/AiHelper') const mockSubscriptions = { 'subscription-123-active': { @@ -59,6 +62,7 @@ describe('SubscriptionController', function () { syncSubscription: sinon.stub().resolves(), attemptPaypalInvoiceCollection: sinon.stub().resolves(), startFreeTrial: sinon.stub().resolves(), + purchaseAddon: sinon.stub().resolves(), }, } @@ -487,28 +491,39 @@ describe('SubscriptionController', function () { }) describe('cancelSubscription', function () { - beforeEach(function (done) { - this.res = { - redirect() { - done() - }, - } - sinon.spy(this.res, 'redirect') - this.SubscriptionController.cancelSubscription(this.req, this.res) - }) - - it('should tell the handler to cancel this user', function (done) { - this.SubscriptionHandler.cancelSubscription + it('should tell the handler to cancel this user', async function () { + this.next = sinon.stub() + await this.SubscriptionController.cancelSubscription( + this.req, + this.res, + this.next + ) + this.SubscriptionHandler.promises.cancelSubscription .calledWith(this.user) .should.equal(true) - done() }) - it('should redurect to the subscription page', function (done) { - this.res.redirect - .calledWith('/user/subscription/canceled') - .should.equal(true) - done() + it('should return a 200 on success', async function () { + this.next = sinon.stub() + await this.SubscriptionController.cancelSubscription( + this.req, + this.res, + this.next + ) + expect(this.res.statusCode).to.equal(200) + }) + + it('should call next with error', async function () { + this.SubscriptionHandler.promises.cancelSubscription.rejects( + new Error('cancel error') + ) + this.next = sinon.stub() + await this.SubscriptionController.cancelSubscription( + this.req, + this.res, + this.next + ) + this.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true) }) }) @@ -624,4 +639,109 @@ describe('SubscriptionController', function () { }) }) }) + + describe('purchaseAddon', function () { + beforeEach(function () { + this.SessionManager.getSessionUser.returns(this.user) // Make sure getSessionUser returns the user + this.next = sinon.stub() + this.req.params = { addOnCode: AI_ADD_ON_CODE } // Mock add-on code + }) + + it('should return 200 on successful purchase of AI add-on', async function () { + await this.SubscriptionController.purchaseAddon( + this.req, + this.res, + this.next + ) + this.res.sendStatus = sinon.spy() + + await this.SubscriptionController.purchaseAddon( + this.req, + this.res, + this.next + ) + + expect(this.SubscriptionHandler.promises.purchaseAddon).to.have.been + .called + expect( + this.SubscriptionHandler.promises.purchaseAddon + ).to.have.been.calledWith(this.user._id, AI_ADD_ON_CODE, 1) + expect( + this.FeaturesUpdater.promises.refreshFeatures + ).to.have.been.calledWith(this.user._id, 'add-on-purchase') + expect(this.res.sendStatus).to.have.been.calledWith(200) + expect(this.logger.debug).to.have.been.calledWith( + { userId: this.user._id, addOnCode: AI_ADD_ON_CODE }, + 'purchasing add-ons' + ) + }) + + it('should return 404 if the add-on code is not AI_ADD_ON_CODE', async function () { + this.req.params = { addOnCode: 'some-other-addon' } + this.res.sendStatus = sinon.spy() + + await this.SubscriptionController.purchaseAddon( + this.req, + this.res, + this.next + ) + + expect(this.SubscriptionHandler.promises.purchaseAddon).to.not.have.been + .called + expect(this.FeaturesUpdater.promises.refreshFeatures).to.not.have.been + .called + expect(this.res.sendStatus).to.have.been.calledWith(404) + }) + + it('should handle DuplicateAddOnError and send badRequest while sending 200', async function () { + this.req.params.addOnCode = AI_ADD_ON_CODE + this.SubscriptionHandler.promises.purchaseAddon.rejects( + new SubscriptionErrors.DuplicateAddOnError() + ) + + await this.SubscriptionController.purchaseAddon( + this.req, + this.res, + this.next + ) + + expect(this.HttpErrorHandler.badRequest).to.have.been.calledWith( + this.req, + this.res, + 'Your subscription already includes this add-on', + { addon: AI_ADD_ON_CODE } + ) + expect( + this.FeaturesUpdater.promises.refreshFeatures + ).to.have.been.calledWith(this.user._id, 'add-on-purchase') + expect(this.res.sendStatus).to.have.been.calledWith(200) + }) + + it('should handle PaymentActionRequiredError and return 402 with details', async function () { + this.req.params.addOnCode = AI_ADD_ON_CODE + const paymentError = new SubscriptionErrors.PaymentActionRequiredError({ + clientSecret: 'secret123', + publicKey: 'pubkey456', + }) + this.SubscriptionHandler.promises.purchaseAddon.rejects(paymentError) + + await this.SubscriptionController.purchaseAddon( + this.req, + this.res, + this.next + ) + + this.res.status.calledWith(402).should.equal(true) + this.res.json + .calledWith({ + message: 'Payment action required', + clientSecret: 'secret123', + publicKey: 'pubkey456', + }) + .should.equal(true) + + expect(this.FeaturesUpdater.promises.refreshFeatures).to.not.have.been + .called + }) + }) }) diff --git a/services/web/test/unit/src/Subscription/SubscriptionGroupController.test.mjs b/services/web/test/unit/src/Subscription/SubscriptionGroupController.test.mjs index 30301ec8cc..4783a44bcb 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionGroupController.test.mjs +++ b/services/web/test/unit/src/Subscription/SubscriptionGroupController.test.mjs @@ -55,7 +55,7 @@ describe('SubscriptionGroupController', function () { getUsersGroupSubscriptionDetails: sinon.stub().resolves({ subscription: ctx.subscription, plan: ctx.plan, - recurlySubscription: ctx.recurlySubscription, + paymentProviderSubscription: ctx.recurlySubscription, }), previewAddSeatsSubscriptionChange: sinon .stub() @@ -73,6 +73,8 @@ describe('SubscriptionGroupController', function () { .resolves(ctx.previewSubscriptionChangeData), checkBillingInfoExistence: sinon.stub().resolves(ctx.paymentMethod), updateSubscriptionPaymentTerms: sinon.stub().resolves(), + ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual: + sinon.stub().resolves(), }, } @@ -105,12 +107,6 @@ describe('SubscriptionGroupController', function () { }, } - ctx.SplitTestHandler = { - promises: { - getAssignment: sinon.stub().resolves({ variant: 'enabled' }), - }, - } - ctx.UserGetter = { promises: { getUserEmail: sinon.stub().resolves(ctx.user.email), @@ -140,6 +136,13 @@ describe('SubscriptionGroupController', function () { InactiveError: class extends Error {}, SubtotalLimitExceededError: class extends Error {}, HasPastDueInvoiceError: class extends Error {}, + HasNoAdditionalLicenseWhenManuallyCollectedError: class extends Error {}, + PaymentActionRequiredError: class extends Error { + constructor(info) { + super('Payment action required') + this.info = info + } + }, } vi.doMock( @@ -171,13 +174,6 @@ describe('SubscriptionGroupController', function () { default: ctx.Modules, })) - vi.doMock( - '../../../../app/src/Features/SplitTests/SplitTestHandler', - () => ({ - default: ctx.SplitTestHandler, - }) - ) - vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({ default: ctx.UserGetter, })) @@ -231,8 +227,8 @@ describe('SubscriptionGroupController', function () { }) describe('removeUserFromGroup', function () { - it('should use the subscription id for the logged in user and take the user id from the params', function (ctx) { - return new Promise(resolve => { + it('should use the subscription id for the logged in user and take the user id from the params', async function (ctx) { + await new Promise(resolve => { const userIdToRemove = '31231' ctx.req.params = { user_id: userIdToRemove } ctx.req.entity = ctx.subscription @@ -252,8 +248,8 @@ describe('SubscriptionGroupController', function () { }) }) - it('should log that the user has been removed', function (ctx) { - return new Promise(resolve => { + it('should log that the user has been removed', async function (ctx) { + await new Promise(resolve => { const userIdToRemove = '31231' ctx.req.params = { user_id: userIdToRemove } ctx.req.entity = ctx.subscription @@ -275,8 +271,8 @@ describe('SubscriptionGroupController', function () { }) }) - it('should call the group SSO hooks with group SSO enabled', function (ctx) { - return new Promise(resolve => { + it('should call the group SSO hooks with group SSO enabled', async function (ctx) { + await new Promise(resolve => { const userIdToRemove = '31231' ctx.req.params = { user_id: userIdToRemove } ctx.req.entity = ctx.subscription @@ -304,8 +300,8 @@ describe('SubscriptionGroupController', function () { }) }) - it('should call the group SSO hooks with group SSO disabled', function (ctx) { - return new Promise(resolve => { + it('should call the group SSO hooks with group SSO disabled', async function (ctx) { + await new Promise(resolve => { const userIdToRemove = '31231' ctx.req.params = { user_id: userIdToRemove } ctx.req.entity = ctx.subscription @@ -328,8 +324,8 @@ describe('SubscriptionGroupController', function () { }) describe('removeSelfFromGroup', function () { - it('gets subscription and remove user', function (ctx) { - return new Promise(resolve => { + it('gets subscription and remove user', async function (ctx) { + await new Promise(resolve => { ctx.req.query = { subscriptionId: ctx.subscriptionId } const memberUserIdToremove = 123456789 ctx.req.session.user._id = memberUserIdToremove @@ -356,8 +352,8 @@ describe('SubscriptionGroupController', function () { }) }) - it('should log that the user has left the subscription', function (ctx) { - return new Promise(resolve => { + it('should log that the user has left the subscription', async function (ctx) { + await new Promise(resolve => { ctx.req.query = { subscriptionId: ctx.subscriptionId } const memberUserIdToremove = '123456789' ctx.req.session.user._id = memberUserIdToremove @@ -379,8 +375,8 @@ describe('SubscriptionGroupController', function () { }) }) - it('should call the group SSO hooks with group SSO enabled', function (ctx) { - return new Promise(resolve => { + it('should call the group SSO hooks with group SSO enabled', async function (ctx) { + await new Promise(resolve => { ctx.req.query = { subscriptionId: ctx.subscriptionId } const memberUserIdToremove = '123456789' ctx.req.session.user._id = memberUserIdToremove @@ -409,8 +405,8 @@ describe('SubscriptionGroupController', function () { }) }) - it('should call the group SSO hooks with group SSO disabled', function (ctx) { - return new Promise(resolve => { + it('should call the group SSO hooks with group SSO disabled', async function (ctx) { + await new Promise(resolve => { const userIdToRemove = '31231' ctx.req.session.user._id = userIdToRemove ctx.req.params = { user_id: userIdToRemove } @@ -434,8 +430,8 @@ describe('SubscriptionGroupController', function () { }) describe('addSeatsToGroupSubscription', function () { - it('should render the "add seats" page', function (ctx) { - return new Promise((resolve, reject) => { + it('should render the "add seats" page', async function (ctx) { + await new Promise((resolve, reject) => { const res = { render: (page, props) => { ctx.SubscriptionGroupHandler.promises.getUsersGroupSubscriptionDetails @@ -456,6 +452,9 @@ describe('SubscriptionGroupController', function () { ctx.SubscriptionGroupHandler.promises.checkBillingInfoExistence .calledWith(ctx.recurlySubscription, ctx.adminUserId) .should.equal(true) + ctx.SubscriptionGroupHandler.promises.ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual + .calledWith(ctx.recurlySubscription) + .should.equal(true) page.should.equal('subscriptions/add-seats') props.subscriptionId.should.equal(ctx.subscriptionId) props.groupName.should.equal(ctx.subscription.teamName) @@ -470,8 +469,8 @@ describe('SubscriptionGroupController', function () { }) }) - it('should redirect to subscription page when getting subscription details fails', function (ctx) { - return new Promise(resolve => { + it('should redirect to subscription page when getting subscription details fails', async function (ctx) { + await new Promise(resolve => { ctx.SubscriptionGroupHandler.promises.getUsersGroupSubscriptionDetails = sinon.stub().rejects() @@ -486,8 +485,8 @@ describe('SubscriptionGroupController', function () { }) }) - it('should redirect to subscription page when flexible licensing is not enabled', function (ctx) { - return new Promise(resolve => { + it('should redirect to subscription page when flexible licensing is not enabled', async function (ctx) { + await new Promise(resolve => { ctx.SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled = sinon.stub().rejects() @@ -502,8 +501,8 @@ describe('SubscriptionGroupController', function () { }) }) - it('should redirect to missing billing information page when billing information is missing', function (ctx) { - return new Promise(resolve => { + it('should redirect to missing billing information page when billing information is missing', async function (ctx) { + await new Promise(resolve => { ctx.SubscriptionGroupHandler.promises.checkBillingInfoExistence = sinon .stub() .throws(new ctx.Errors.MissingBillingInfoError()) @@ -521,8 +520,30 @@ describe('SubscriptionGroupController', function () { }) }) - it('should redirect to subscription page when there is a pending change', function (ctx) { - return new Promise(resolve => { + it('should redirect to manually collected subscription error page when collection method is manual and has no additional license add-on', async function (ctx) { + await new Promise(resolve => { + ctx.SubscriptionGroupHandler.promises.ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual = + sinon + .stub() + .throws( + new ctx.Errors.HasNoAdditionalLicenseWhenManuallyCollectedError() + ) + + const res = { + redirect: url => { + url.should.equal( + '/user/subscription/group/manually-collected-subscription' + ) + resolve() + }, + } + + ctx.Controller.addSeatsToGroupSubscription(ctx.req, res) + }) + }) + + it('should redirect to subscription page when there is a pending change', async function (ctx) { + await new Promise(resolve => { ctx.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPendingChanges = sinon.stub().throws(new ctx.Errors.PendingChangeError()) @@ -537,8 +558,8 @@ describe('SubscriptionGroupController', function () { }) }) - it('should redirect to subscription page when subscription is not active', function (ctx) { - return new Promise(resolve => { + it('should redirect to subscription page when subscription is not active', async function (ctx) { + await new Promise(resolve => { ctx.SubscriptionGroupHandler.promises.ensureSubscriptionIsActive = sinon .stub() .rejects() @@ -554,10 +575,11 @@ describe('SubscriptionGroupController', function () { }) }) - it('should redirect to subscription page when subscription has pending invoice', function (ctx) { + it('should redirect to subscription page when subscription has pending invoice', async function (ctx) { ctx.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPastDueInvoice = sinon.stub().rejects() - return new Promise(resolve => { + + await new Promise(resolve => { const res = { redirect: url => { url.should.equal('/user/subscription') @@ -571,8 +593,8 @@ describe('SubscriptionGroupController', function () { }) describe('previewAddSeatsSubscriptionChange', function () { - it('should preview "add seats" change', function (ctx) { - return new Promise(resolve => { + it('should preview "add seats" change', async function (ctx) { + await new Promise(resolve => { ctx.req.body = { adding: 2 } const res = { @@ -589,8 +611,8 @@ describe('SubscriptionGroupController', function () { }) }) - it('should fail previewing "add seats" change', function (ctx) { - return new Promise(resolve => { + it('should fail previewing "add seats" change', async function (ctx) { + await new Promise(resolve => { ctx.SubscriptionGroupHandler.promises.previewAddSeatsSubscriptionChange = sinon.stub().rejects() @@ -610,8 +632,8 @@ describe('SubscriptionGroupController', function () { }) }) - it('should fail previewing "add seats" change with SubtotalLimitExceededError', function (ctx) { - return new Promise(resolve => { + it('should fail previewing "add seats" change with SubtotalLimitExceededError', async function (ctx) { + await new Promise(resolve => { ctx.req.body = { adding: 2 } ctx.SubscriptionGroupHandler.promises.previewAddSeatsSubscriptionChange = sinon.stub().throws(new ctx.Errors.SubtotalLimitExceededError()) @@ -638,8 +660,8 @@ describe('SubscriptionGroupController', function () { }) describe('createAddSeatsSubscriptionChange', function () { - it('should apply "add seats" change', function (ctx) { - return new Promise(resolve => { + it('should apply "add seats" change', async function (ctx) { + await new Promise(resolve => { ctx.req.body = { adding: 2 } const res = { @@ -656,8 +678,8 @@ describe('SubscriptionGroupController', function () { }) }) - it('should fail applying "add seats" change', function (ctx) { - return new Promise(resolve => { + it('should fail applying "add seats" change', async function (ctx) { + await new Promise(resolve => { ctx.SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange = sinon.stub().rejects() @@ -677,8 +699,8 @@ describe('SubscriptionGroupController', function () { }) }) - it('should fail applying "add seats" change with SubtotalLimitExceededError', function (ctx) { - return new Promise(resolve => { + it('should fail applying "add seats" change with SubtotalLimitExceededError', async function (ctx) { + await new Promise(resolve => { ctx.req.body = { adding: 2 } ctx.SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange = sinon.stub().throws(new ctx.Errors.SubtotalLimitExceededError()) @@ -702,11 +724,43 @@ describe('SubscriptionGroupController', function () { ctx.Controller.createAddSeatsSubscriptionChange(ctx.req, res) }) }) + + it('should send 402 response with PaymentActionRequiredError', async function (ctx) { + await new Promise(resolve => { + const adding = 2 + ctx.req.body = { adding } + const error = new ctx.Errors.PaymentActionRequiredError({ + clientSecret: 'secret', + publicKey: 'key', + }) + ctx.SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange = + sinon.stub().throws(error) + + const res = { + status: statusCode => { + statusCode.should.equal(402) + + return { + json: data => { + data.should.deep.equal({ + message: 'Payment action required', + clientSecret: error.info.clientSecret, + publicKey: error.info.publicKey, + }) + resolve() + }, + } + }, + } + + ctx.Controller.createAddSeatsSubscriptionChange(ctx.req, res) + }) + }) }) describe('submitForm', function () { - it('should build and pass the request body to the sales submit handler', function (ctx) { - return new Promise(resolve => { + it('should build and pass the request body to the sales submit handler', async function (ctx) { + await new Promise(resolve => { const adding = 100 const poNumber = 'PO123456' ctx.req.body = { adding, poNumber } @@ -747,8 +801,8 @@ describe('SubscriptionGroupController', function () { }) describe('subscriptionUpgradePage', function () { - it('should render "subscription upgrade" page', function (ctx) { - return new Promise(resolve => { + it('should render "subscription upgrade" page', async function (ctx) { + await new Promise(resolve => { const olSubscription = { membersLimit: 1, teamName: 'test team' } ctx.SubscriptionModel.Subscription.findOne = () => { return { @@ -773,8 +827,8 @@ describe('SubscriptionGroupController', function () { }) }) - it('should redirect if failed to generate preview', function (ctx) { - return new Promise(resolve => { + it('should redirect if failed to generate preview', async function (ctx) { + await new Promise(resolve => { ctx.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview = sinon .stub() .rejects() @@ -790,8 +844,8 @@ describe('SubscriptionGroupController', function () { }) }) - it('should redirect to missing billing information page when billing information is missing', function (ctx) { - return new Promise(resolve => { + it('should redirect to missing billing information page when billing information is missing', async function (ctx) { + await new Promise(resolve => { ctx.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview = sinon .stub() .throws(new ctx.Errors.MissingBillingInfoError()) @@ -809,8 +863,8 @@ describe('SubscriptionGroupController', function () { }) }) - it('should redirect to manually collected subscription error page when collection method is manual', function (ctx) { - return new Promise(resolve => { + it('should redirect to manually collected subscription error page when collection method is manual', async function (ctx) { + await new Promise(resolve => { ctx.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview = sinon .stub() .throws(new ctx.Errors.ManuallyCollectedError()) @@ -828,8 +882,8 @@ describe('SubscriptionGroupController', function () { }) }) - it('should redirect to subtotal limit exceeded page', function (ctx) { - return new Promise(resolve => { + it('should redirect to subtotal limit exceeded page', async function (ctx) { + await new Promise(resolve => { ctx.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview = sinon .stub() .throws(new ctx.Errors.SubtotalLimitExceededError()) @@ -847,8 +901,8 @@ describe('SubscriptionGroupController', function () { }) describe('upgradeSubscription', function () { - it('should send 200 response', function (ctx) { - return new Promise(resolve => { + it('should send 200 response', async function (ctx) { + await new Promise(resolve => { ctx.SubscriptionGroupHandler.promises.upgradeGroupPlan = sinon .stub() .resolves() @@ -864,8 +918,8 @@ describe('SubscriptionGroupController', function () { }) }) - it('should send 500 response', function (ctx) { - return new Promise(resolve => { + it('should send 500 response', async function (ctx) { + await new Promise(resolve => { ctx.SubscriptionGroupHandler.promises.upgradeGroupPlan = sinon .stub() .rejects() @@ -880,5 +934,34 @@ describe('SubscriptionGroupController', function () { ctx.Controller.upgradeSubscription(ctx.req, res) }) }) + + it('should send 402 response with PaymentActionRequiredError', async function (ctx) { + await new Promise(resolve => { + const error = new ctx.Errors.PaymentActionRequiredError({ + clientSecret: 'secret', + publicKey: 'public', + }) + ctx.SubscriptionGroupHandler.promises.upgradeGroupPlan = sinon + .stub() + .rejects(error) + const res = { + status: code => { + code.should.equal(402) + return { + json: data => { + data.should.deep.equal({ + message: 'Payment action required', + clientSecret: error.info.clientSecret, + publicKey: error.info.publicKey, + }) + resolve() + }, + } + }, + } + + ctx.Controller.upgradeSubscription(ctx.req, res) + }) + }) }) }) diff --git a/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js b/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js index 1c314458da..87793fe440 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js @@ -193,21 +193,26 @@ describe('SubscriptionGroupHandler', function () { this.Modules = { promises: { hooks: { - fire: sinon.stub().callsFake(hookName => { - if (hookName === 'generateTermsAndConditions') { - return Promise.resolve(['T&Cs']) - } - if (hookName === 'getPaymentFromRecord') { - return Promise.resolve([ - { account: { hasPastDueInvoice: false } }, - ]) - } - return Promise.resolve() - }), + fire: sinon.stub(), }, }, } + this.Modules.promises.hooks.fire + .withArgs('generateTermsAndConditions') + .resolves(['T&Cs']) + .withArgs('getPaymentFromRecord') + .resolves([ + { + subscription: this.recurlySubscription, + account: { hasPastDueInvoice: false }, + }, + ]) + .withArgs('previewSubscriptionChangeRequest') + .resolves([this.previewSubscriptionChange]) + .withArgs('previewGroupPlanUpgrade') + .resolves([{ subscriptionChange: this.previewSubscriptionChange }]) + this.Handler = SandboxedModule.require(modulePath, { requires: { './SubscriptionUpdater': this.SubscriptionUpdater, @@ -389,7 +394,7 @@ describe('SubscriptionGroupHandler', function () { this.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE, canUseFlexibleLicensing: true, }, - recurlySubscription: this.recurlySubscription, + paymentProviderSubscription: this.recurlySubscription, }) }) }) @@ -441,11 +446,16 @@ describe('SubscriptionGroupHandler', function () { this.adminUser_id, this.adding ) - this.RecurlyClient.promises.getPaymentMethod - .calledWith(this.adminUser_id) + this.Modules.promises.hooks.fire + .calledWith('getPaymentFromRecord', { + groupPlan: true, + recurlyStatus: { + state: 'active', + }, + }) .should.equal(true) - this.RecurlyClient.promises.previewSubscriptionChange - .calledWith(this.changeRequest) + this.Modules.promises.hooks.fire + .calledWith('previewSubscriptionChangeRequest', this.changeRequest) .should.equal(true) this.SubscriptionController.makeChangePreview .calledWith( @@ -473,9 +483,14 @@ describe('SubscriptionGroupHandler', function () { return true }, } - this.RecurlyClient.promises.getSubscription = sinon - .stub() - .resolves(this.recurlySubscription) + this.Modules.promises.hooks.fire + .withArgs('getPaymentFromRecord') + .resolves([ + { + subscription: this.recurlySubscription, + account: { hasPastDueInvoice: false }, + }, + ]) const result = await this.Handler.promises.createAddSeatsSubscriptionChange( @@ -491,13 +506,10 @@ describe('SubscriptionGroupHandler', function () { .and(sinon.match.has('termsAndConditions')) ) .should.equal(true) - this.RecurlyClient.promises.applySubscriptionChangeRequest - .calledWith(this.changeRequest) - .should.equal(true) - this.SubscriptionHandler.promises.syncSubscription + this.Modules.promises.hooks.fire .calledWith( - { uuid: this.recurlySubscription.id }, - this.adminUser_id + 'applySubscriptionChangeRequestAndSync', + this.changeRequest ) .should.equal(true) expect(result).to.deep.equal({ @@ -511,7 +523,6 @@ describe('SubscriptionGroupHandler', function () { describe('accounts with PO number', function () { it('should update the subscription PO number and T&C', async function () { await this.Handler.promises.updateSubscriptionPaymentTerms( - this.adminUser_id, this.recurlySubscription, this.poNumberAndTermsAndConditionsUpdate.poNumber ) @@ -525,12 +536,23 @@ describe('SubscriptionGroupHandler', function () { .calledWith(this.poNumberAndTermsAndConditionsUpdate) .should.equal(true) }) + + it('should fail for stripe', async function () { + this.recurlySubscription.service = 'stripe' + await expect( + this.Handler.promises.updateSubscriptionPaymentTerms( + this.recurlySubscription, + this.poNumberAndTermsAndConditionsUpdate.poNumber + ) + ).to.be.rejectedWith( + 'Updating payment terms is not supported for Stripe subscriptions' + ) + }) }) describe('accounts with no PO number', function () { it('should update the subscription T&C only', async function () { await this.Handler.promises.updateSubscriptionPaymentTerms( - this.adminUser_id, this.recurlySubscription ) this.recurlySubscription.getRequestForTermsAndConditionsUpdate @@ -570,11 +592,16 @@ describe('SubscriptionGroupHandler', function () { let preview afterEach(function () { - this.RecurlyClient.promises.getPaymentMethod - .calledWith(this.adminUser_id) + this.Modules.promises.hooks.fire + .calledWith('getPaymentFromRecord', { + groupPlan: true, + recurlyStatus: { + state: 'active', + }, + }) .should.equal(true) - this.RecurlyClient.promises.previewSubscriptionChange - .calledWith(this.changeRequest) + this.Modules.promises.hooks.fire + .calledWith('previewSubscriptionChangeRequest', this.changeRequest) .should.equal(true) this.SubscriptionController.makeChangePreview .calledWith( @@ -779,100 +806,54 @@ describe('SubscriptionGroupHandler', function () { }) }) - describe('upgradeGroupPlan', function () { - it('should upgrade the subscription for flexible licensing group plans', async function () { - this.SubscriptionLocator.promises.getUsersSubscription = sinon - .stub() - .resolves({ - groupPlan: true, - recurlyStatus: { - state: 'active', - }, - planCode: 'group_collaborator', - }) - await this.Handler.promises.upgradeGroupPlan(this.user_id) - this.recurlySubscription.getRequestForGroupPlanUpgrade - .calledWith('group_professional') - .should.equal(true) - this.RecurlyClient.promises.applySubscriptionChangeRequest - .calledWith(this.changeRequest) - .should.equal(true) - this.SubscriptionHandler.promises.syncSubscription - .calledWith({ uuid: this.changeRequest.subscription.id }, this.user_id) - .should.equal(true) - }) - - it('should upgrade the subscription for legacy group plans', async function () { - this.SubscriptionLocator.promises.getUsersSubscription = sinon - .stub() - .resolves({ - groupPlan: true, - recurlyStatus: { - state: 'active', - }, - planCode: 'group_collaborator_10_educational', - }) - await this.Handler.promises.upgradeGroupPlan(this.user_id) - this.recurlySubscription.getRequestForGroupPlanUpgrade - .calledWith('group_professional_10_educational') - .should.equal(true) - this.RecurlyClient.promises.applySubscriptionChangeRequest - .calledWith(this.changeRequest) - .should.equal(true) - this.SubscriptionHandler.promises.syncSubscription - .calledWith({ uuid: this.changeRequest.subscription.id }, this.user_id) - .should.equal(true) - }) - - it('should fail the upgrade if is professional already', async function () { - this.SubscriptionLocator.promises.getUsersSubscription = sinon - .stub() - .resolves({ - groupPlan: true, - recurlyStatus: { - state: 'active', - }, - planCode: 'group_professional', - }) + describe('ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual', function () { + it('should throw if the subscription is manually collected and has no additional license add-on', async function () { await expect( - this.Handler.promises.upgradeGroupPlan(this.user_id) - ).to.be.rejectedWith('Not eligible for group plan upgrade') + this.Handler.promises.ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual( + { + isCollectionMethodManual: true, + hasAddOn: sinon + .stub() + .withArgs('additional-license') + .returns(false), + } + ) + ).to.be.rejectedWith( + 'This subscription is being collected manually has no "additional-license" add-on' + ) }) - it('should fail the upgrade if not group plan', async function () { - this.SubscriptionLocator.promises.getUsersSubscription = sinon - .stub() - .resolves({ - groupPlan: false, - recurlyStatus: { - state: 'active', - }, - planCode: 'test_plan_code', - }) + it('should not throw if the subscription is not manually collected and has no additional license add-on and ', async function () { await expect( - this.Handler.promises.upgradeGroupPlan(this.user_id) - ).to.be.rejectedWith('Not eligible for group plan upgrade') + this.Handler.promises.ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual( + { + isCollectionMethodManual: false, + hasAddOn: sinon + .stub() + .withArgs('additional-license') + .returns(false), + } + ) + ).to.not.be.rejected + }) + + it('should not throw if the subscription is not manually collected and has additional license add-on', async function () { + await expect( + this.Handler.promises.ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual( + { + isCollectionMethodManual: true, + hasAddOn: sinon.stub().withArgs('additional-license').returns(true), + } + ) + ).to.not.be.rejected }) }) describe('getGroupPlanUpgradePreview', function () { it('should generate preview for subscription upgrade', async function () { - this.SubscriptionLocator.promises.getUsersSubscription = sinon - .stub() - .resolves({ - groupPlan: true, - recurlyStatus: { - state: 'active', - }, - planCode: 'group_collaborator', - }) const result = await this.Handler.promises.getGroupPlanUpgradePreview( this.user_id ) - this.RecurlyClient.promises.previewSubscriptionChange - .calledWith(this.changeRequest) - .should.equal(true) - result.should.equal(this.changePreview) }) }) @@ -883,8 +864,8 @@ describe('SubscriptionGroupHandler', function () { this.recurlySubscription, this.adminUser_id ) - this.RecurlyClient.promises.getPaymentMethod - .calledWith(this.adminUser_id) + this.Modules.promises.hooks.fire + .calledWith('getPaymentMethod', this.adminUser_id) .should.equal(true) }) diff --git a/services/web/test/unit/src/Subscription/SubscriptionHelperTests.js b/services/web/test/unit/src/Subscription/SubscriptionHelperTests.js index fb667ca451..4c618221ca 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionHelperTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionHelperTests.js @@ -184,10 +184,6 @@ describe('SubscriptionHelper', function () { collaborator: '5 CHF', professional: '50 CHF', }, - pricePerUserPerMonth: { - collaborator: '0,45 CHF', - professional: '4,20 CHF', - }, }) }) }) @@ -209,10 +205,6 @@ describe('SubscriptionHelper', function () { collaborator: '10 kr.', professional: '100 kr.', }, - pricePerUserPerMonth: { - collaborator: '0,85 kr.', - professional: '8,35 kr.', - }, }) }) }) @@ -234,10 +226,6 @@ describe('SubscriptionHelper', function () { collaborator: '15 kr', professional: '150 kr', }, - pricePerUserPerMonth: { - collaborator: '1,25 kr', - professional: '12,50 kr', - }, }) }) }) @@ -261,10 +249,6 @@ describe('SubscriptionHelper', function () { collaborator: 'kr 20', professional: 'kr 200', }, - pricePerUserPerMonth: { - collaborator: 'kr 1.70', - professional: 'kr 16.70', - }, }) }) }) @@ -286,10 +270,6 @@ describe('SubscriptionHelper', function () { collaborator: '$25', professional: '$250', }, - pricePerUserPerMonth: { - collaborator: '$2.10', - professional: '$20.85', - }, }) }) }) diff --git a/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js b/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js index 86eb51070e..235abf600e 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js @@ -150,6 +150,11 @@ describe('SubscriptionViewModelBuilder', function () { this.PlansLocator = { findLocalPlanInSettings: sinon.stub(), } + this.SplitTestHandler = { + promises: { + getAssignmentForUser: sinon.stub().resolves({ variant: 'default' }), + }, + } this.SubscriptionViewModelBuilder = SandboxedModule.require(modulePath, { requires: { '@overleaf/settings': this.Settings, @@ -168,6 +173,7 @@ describe('SubscriptionViewModelBuilder', function () { './V1SubscriptionManager': {}, '../Publishers/PublishersGetter': this.PublishersGetter, './SubscriptionHelper': SubscriptionHelper, + '../SplitTests/SplitTestHandler': this.SplitTestHandler, }, }) @@ -659,6 +665,9 @@ describe('SubscriptionViewModelBuilder', function () { describe('isEligibleForGroupPlan', function () { it('is false for Stripe subscriptions', async function () { this.paymentRecord.service = 'stripe-us' + this.Modules.promises.hooks.fire + .withArgs('canUpgradeFromIndividualToGroup') + .resolves([false]) const result = await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( this.user diff --git a/services/web/test/unit/src/Subscription/TeamInvitesController.test.mjs b/services/web/test/unit/src/Subscription/TeamInvitesController.test.mjs index be5fe26670..0d777a5b44 100644 --- a/services/web/test/unit/src/Subscription/TeamInvitesController.test.mjs +++ b/services/web/test/unit/src/Subscription/TeamInvitesController.test.mjs @@ -144,8 +144,8 @@ describe('TeamInvitesController', function () { }) describe('acceptInvite', function () { - it('should add an audit log entry', function (ctx) { - return new Promise(resolve => { + it('should add an audit log entry', async function (ctx) { + await new Promise(resolve => { ctx.req.params.token = 'foo' ctx.req.session.user = ctx.user const res = { @@ -176,8 +176,8 @@ describe('TeamInvitesController', function () { } describe('hasIndividualPaidSubscription', function () { - it('is true for personal subscription', function (ctx) { - return new Promise(resolve => { + it('is true for personal subscription', async function (ctx) { + await new Promise(resolve => { ctx.SubscriptionLocator.promises.getUsersSubscription.resolves({ recurlySubscription_id: 'subscription123', groupPlan: false, @@ -192,8 +192,8 @@ describe('TeamInvitesController', function () { }) }) - it('is true for group subscriptions', function (ctx) { - return new Promise(resolve => { + it('is true for group subscriptions', async function (ctx) { + await new Promise(resolve => { ctx.SubscriptionLocator.promises.getUsersSubscription.resolves({ recurlySubscription_id: 'subscription123', groupPlan: true, @@ -208,8 +208,8 @@ describe('TeamInvitesController', function () { }) }) - it('is false for canceled subscriptions', function (ctx) { - return new Promise(resolve => { + it('is false for canceled subscriptions', async function (ctx) { + await new Promise(resolve => { ctx.SubscriptionLocator.promises.getUsersSubscription.resolves({ recurlySubscription_id: 'subscription123', groupPlan: false, @@ -229,8 +229,8 @@ describe('TeamInvitesController', function () { }) describe('when user is logged out', function () { - it('renders logged out invite page', function (ctx) { - return new Promise(resolve => { + it('renders logged out invite page', async function (ctx) { + await new Promise(resolve => { const res = { render: (template, data) => { expect(template).to.equal('subscriptions/team/invite_logged_out') @@ -245,8 +245,8 @@ describe('TeamInvitesController', function () { }) }) - it('includes groupSSOActive flag when the group has SSO enabled', function (ctx) { - return new Promise(resolve => { + it('includes groupSSOActive flag when the group has SSO enabled', async function (ctx) { + await new Promise(resolve => { ctx.Modules.promises.hooks.fire = sinon.stub().resolves([true]) const res = { render: (template, data) => { @@ -262,8 +262,8 @@ describe('TeamInvitesController', function () { }) }) - it('renders the view', function (ctx) { - return new Promise(resolve => { + it('renders the view', async function (ctx) { + await new Promise(resolve => { const res = { render: template => { expect(template).to.equal('subscriptions/team/invite') diff --git a/services/web/test/unit/src/Tags/TagsController.test.mjs b/services/web/test/unit/src/Tags/TagsController.test.mjs index c8cb739d0e..178217a834 100644 --- a/services/web/test/unit/src/Tags/TagsController.test.mjs +++ b/services/web/test/unit/src/Tags/TagsController.test.mjs @@ -56,8 +56,8 @@ describe('TagsController', function () { ctx.res.json = sinon.stub() }) - it('get all tags', function (ctx) { - return new Promise(resolve => { + it('get all tags', async function (ctx) { + await new Promise(resolve => { const allTags = [{ name: 'tag', projects: ['123423', '423423'] }] ctx.TagsHandler.promises.getAllTags = sinon.stub().resolves(allTags) ctx.TagsController.getAllTags(ctx.req, { @@ -74,8 +74,8 @@ describe('TagsController', function () { }) describe('create a tag', function (done) { - it('without a color', function (ctx) { - return new Promise(resolve => { + it('without a color', async function (ctx) { + await new Promise(resolve => { ctx.tag = { mock: 'tag' } ctx.TagsHandler.promises.createTag = sinon.stub().resolves(ctx.tag) ctx.req.session.user._id = ctx.userId = 'user-id-123' @@ -96,8 +96,8 @@ describe('TagsController', function () { }) }) - it('with a color', function (ctx) { - return new Promise(resolve => { + it('with a color', async function (ctx) { + await new Promise(resolve => { ctx.tag = { mock: 'tag' } ctx.TagsHandler.promises.createTag = sinon.stub().resolves(ctx.tag) ctx.req.session.user._id = ctx.userId = 'user-id-123' @@ -123,8 +123,8 @@ describe('TagsController', function () { }) }) - it('delete a tag', function (ctx) { - return new Promise(resolve => { + it('delete a tag', async function (ctx) { + await new Promise(resolve => { ctx.req.params.tagId = ctx.tagId = 'tag-id-123' ctx.req.session.user._id = ctx.userId = 'user-id-123' ctx.TagsController.deleteTag(ctx.req, { @@ -150,8 +150,8 @@ describe('TagsController', function () { ctx.req.session.user._id = ctx.userId = 'user-id-123' }) - it('with a name and no color', function (ctx) { - return new Promise(resolve => { + it('with a name and no color', async function (ctx) { + await new Promise(resolve => { ctx.req.body = { name: (ctx.tagName = 'new-name'), } @@ -173,8 +173,8 @@ describe('TagsController', function () { }) }) - it('with a name and color', function (ctx) { - return new Promise(resolve => { + it('with a name and color', async function (ctx) { + await new Promise(resolve => { ctx.req.body = { name: (ctx.tagName = 'new-name'), color: (ctx.color = '#FF0011'), @@ -198,8 +198,8 @@ describe('TagsController', function () { }) }) - it('without a name', function (ctx) { - return new Promise(resolve => { + it('without a name', async function (ctx) { + await new Promise(resolve => { ctx.req.body = { name: undefined } ctx.TagsController.renameTag(ctx.req, { status: code => { @@ -215,8 +215,8 @@ describe('TagsController', function () { }) }) - it('add a project to a tag', function (ctx) { - return new Promise(resolve => { + it('add a project to a tag', async function (ctx) { + await new Promise(resolve => { ctx.req.params.tagId = ctx.tagId = 'tag-id-123' ctx.req.params.projectId = ctx.projectId = 'project-id-123' ctx.req.session.user._id = ctx.userId = 'user-id-123' @@ -238,8 +238,8 @@ describe('TagsController', function () { }) }) - it('add projects to a tag', function (ctx) { - return new Promise(resolve => { + it('add projects to a tag', async function (ctx) { + await new Promise(resolve => { ctx.req.params.tagId = ctx.tagId = 'tag-id-123' ctx.req.body.projectIds = ctx.projectIds = [ 'project-id-123', @@ -264,8 +264,8 @@ describe('TagsController', function () { }) }) - it('remove a project from a tag', function (ctx) { - return new Promise(resolve => { + it('remove a project from a tag', async function (ctx) { + await new Promise(resolve => { ctx.req.params.tagId = ctx.tagId = 'tag-id-123' ctx.req.params.projectId = ctx.projectId = 'project-id-123' ctx.req.session.user._id = ctx.userId = 'user-id-123' @@ -287,8 +287,8 @@ describe('TagsController', function () { }) }) - it('remove projects from a tag', function (ctx) { - return new Promise(resolve => { + it('remove projects from a tag', async function (ctx) { + await new Promise(resolve => { ctx.req.params.tagId = ctx.tagId = 'tag-id-123' ctx.req.body.projectIds = ctx.projectIds = [ 'project-id-123', diff --git a/services/web/test/unit/src/ThirdPartyDataStore/TpdsController.test.mjs b/services/web/test/unit/src/ThirdPartyDataStore/TpdsController.test.mjs index 29daa00efc..b38e3c2bab 100644 --- a/services/web/test/unit/src/ThirdPartyDataStore/TpdsController.test.mjs +++ b/services/web/test/unit/src/ThirdPartyDataStore/TpdsController.test.mjs @@ -116,8 +116,8 @@ describe('TpdsController', function () { }) describe('creating a project', function () { - it('should yield the new projects id', function (ctx) { - return new Promise(resolve => { + it('should yield the new projects id', async function (ctx) { + await new Promise(resolve => { const res = new MockResponse() const req = new MockRequest() req.params.user_id = ctx.user_id @@ -161,8 +161,8 @@ describe('TpdsController', function () { } }) - it('should process the update with the update receiver by name', function (ctx) { - return new Promise(resolve => { + it('should process the update with the update receiver by name', async function (ctx) { + await new Promise(resolve => { const res = { json: payload => { expect(payload).to.deep.equal({ @@ -190,8 +190,8 @@ describe('TpdsController', function () { }) }) - it('should indicate in the response when the update was rejected', function (ctx) { - return new Promise(resolve => { + it('should indicate in the response when the update was rejected', async function (ctx) { + await new Promise(resolve => { ctx.TpdsUpdateHandler.promises.newUpdate.resolves(null) const res = { json: payload => { @@ -203,8 +203,8 @@ describe('TpdsController', function () { }) }) - it('should process the update with the update receiver by id', function (ctx) { - return new Promise(resolve => { + it('should process the update with the update receiver by id', async function (ctx) { + await new Promise(resolve => { const path = '/here.txt' const req = { pause() {}, @@ -233,8 +233,8 @@ describe('TpdsController', function () { }) }) - it('should return a 500 error when the update receiver fails', function (ctx) { - return new Promise(resolve => { + it('should return a 500 error when the update receiver fails', async function (ctx) { + await new Promise(resolve => { ctx.TpdsUpdateHandler.promises.newUpdate.rejects(new Error()) const res = { json: sinon.stub(), @@ -247,8 +247,8 @@ describe('TpdsController', function () { }) }) - it('should return a 400 error when the project is too big', function (ctx) { - return new Promise(resolve => { + it('should return a 400 error when the project is too big', async function (ctx) { + await new Promise(resolve => { ctx.TpdsUpdateHandler.promises.newUpdate.rejects({ message: 'project_has_too_many_files', }) @@ -265,8 +265,8 @@ describe('TpdsController', function () { }) }) - it('should return a 429 error when the update receiver fails due to too many requests error', function (ctx) { - return new Promise(resolve => { + it('should return a 429 error when the update receiver fails due to too many requests error', async function (ctx) { + await new Promise(resolve => { ctx.TpdsUpdateHandler.promises.newUpdate.rejects( new Errors.TooManyRequestsError('project on cooldown') ) @@ -282,8 +282,8 @@ describe('TpdsController', function () { }) describe('getting a delete update', function () { - it('should process the delete with the update receiver by name', function (ctx) { - return new Promise(resolve => { + it('should process the delete with the update receiver by name', async function (ctx) { + await new Promise(resolve => { const path = '/projectName/here.txt' const req = { params: { 0: path, user_id: ctx.user_id, project_id: '' }, @@ -312,8 +312,8 @@ describe('TpdsController', function () { }) }) - it('should process the delete with the update receiver by id', function (ctx) { - return new Promise(resolve => { + it('should process the delete with the update receiver by id', async function (ctx) { + await new Promise(resolve => { const path = '/here.txt' const req = { params: { 0: path, user_id: ctx.user_id, project_id: '123' }, @@ -351,8 +351,8 @@ describe('TpdsController', function () { } }) - it("creates a folder if it doesn't exist", function (ctx) { - return new Promise(resolve => { + it("creates a folder if it doesn't exist", async function (ctx) { + await new Promise(resolve => { const metadata = { folderId: new ObjectId(), projectId: new ObjectId(), @@ -373,8 +373,8 @@ describe('TpdsController', function () { }) }) - it('supports top level folders', function (ctx) { - return new Promise(resolve => { + it('supports top level folders', async function (ctx) { + await new Promise(resolve => { const metadata = { folderId: new ObjectId(), projectId: new ObjectId(), @@ -395,8 +395,8 @@ describe('TpdsController', function () { }) }) - it("returns a 409 if the folder couldn't be created", function (ctx) { - return new Promise(resolve => { + it("returns a 409 if the folder couldn't be created", async function (ctx) { + await new Promise(resolve => { ctx.TpdsUpdateHandler.promises.createFolder.resolves(null) ctx.HttpErrorHandler.conflict.callsFake((req, res) => { expect(req).to.equal(ctx.req) @@ -523,8 +523,8 @@ describe('TpdsController', function () { }) describe('success', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.res.json.callsFake(() => { resolve() }) @@ -546,8 +546,8 @@ describe('TpdsController', function () { }) describe('error', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.err = new Error() ctx.TpdsQueueManager.promises.getQueues = sinon .stub() diff --git a/services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateHandler.test.mjs b/services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateHandler.test.mjs index 08a7dcf494..f58ef6ec1c 100644 --- a/services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateHandler.test.mjs +++ b/services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateHandler.test.mjs @@ -491,8 +491,8 @@ function receiveFileDelete() { } function receiveFileDeleteById() { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.TpdsUpdateHandler.deleteUpdate( ctx.userId, ctx.projectId, diff --git a/services/web/test/unit/src/TokenAccess/TokenAccessController.test.mjs b/services/web/test/unit/src/TokenAccess/TokenAccessController.test.mjs index 96d2d19b04..7586757cf0 100644 --- a/services/web/test/unit/src/TokenAccess/TokenAccessController.test.mjs +++ b/services/web/test/unit/src/TokenAccess/TokenAccessController.test.mjs @@ -285,21 +285,21 @@ describe('TokenAccessController', function () { }) describe('normal case (edit slot available)', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { - ctx.LimitationsManager.promises.canAcceptEditCollaboratorInvite.resolves( - true - ) - ctx.req.params = { token: ctx.token } - ctx.req.body = { - confirmedByUser: true, - tokenHashPrefix: '#prefix', - } + beforeEach(async function (ctx) { + ctx.LimitationsManager.promises.canAcceptEditCollaboratorInvite.resolves( + true + ) + ctx.req.params = { token: ctx.token } + ctx.req.body = { + confirmedByUser: true, + tokenHashPrefix: '#prefix', + } + await new Promise((resolve, reject) => { ctx.res.callback = resolve ctx.TokenAccessController.grantTokenAccessReadAndWrite( ctx.req, ctx.res, - resolve + ctx.rejectOnError(reject) ) }) }) @@ -361,21 +361,21 @@ describe('TokenAccessController', function () { }) describe('when there are no edit collaborator slots available', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { - ctx.LimitationsManager.promises.canAcceptEditCollaboratorInvite.resolves( - false - ) - ctx.req.params = { token: ctx.token } - ctx.req.body = { - confirmedByUser: true, - tokenHashPrefix: '#prefix', - } + beforeEach(async function (ctx) { + ctx.LimitationsManager.promises.canAcceptEditCollaboratorInvite.resolves( + false + ) + ctx.req.params = { token: ctx.token } + ctx.req.body = { + confirmedByUser: true, + tokenHashPrefix: '#prefix', + } + await new Promise((resolve, reject) => { ctx.res.callback = resolve ctx.TokenAccessController.grantTokenAccessReadAndWrite( ctx.req, ctx.res, - resolve + ctx.rejectOnError(reject) ) }) }) @@ -439,16 +439,16 @@ describe('TokenAccessController', function () { }) describe('when the access was already granted', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { - ctx.project.tokenAccessReadAndWrite_refs.push(ctx.user._id) - ctx.req.params = { token: ctx.token } - ctx.req.body = { confirmedByUser: true } + beforeEach(async function (ctx) { + ctx.project.tokenAccessReadAndWrite_refs.push(ctx.user._id) + ctx.req.params = { token: ctx.token } + ctx.req.body = { confirmedByUser: true } + await new Promise((resolve, reject) => { ctx.res.callback = resolve ctx.TokenAccessController.grantTokenAccessReadAndWrite( ctx.req, ctx.res, - resolve + ctx.rejectOnError(reject) ) }) }) @@ -479,15 +479,15 @@ describe('TokenAccessController', function () { }) describe('hash prefix missing in request', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { - ctx.req.params = { token: ctx.token } - ctx.req.body = { confirmedByUser: true } + beforeEach(async function (ctx) { + ctx.req.params = { token: ctx.token } + ctx.req.body = { confirmedByUser: true } + await new Promise((resolve, reject) => { ctx.res.callback = resolve ctx.TokenAccessController.grantTokenAccessReadAndWrite( ctx.req, ctx.res, - resolve + ctx.rejectOnError(reject) ) }) }) @@ -517,8 +517,8 @@ describe('TokenAccessController', function () { }) describe('user is owner of project', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise((resolve, reject) => { ctx.AuthorizationManager.promises.getPrivilegeLevelForProject.returns( PrivilegeLevels.OWNER ) @@ -528,7 +528,7 @@ describe('TokenAccessController', function () { ctx.TokenAccessController.grantTokenAccessReadAndWrite( ctx.req, ctx.res, - resolve + ctx.rejectOnError(reject) ) }) }) @@ -555,13 +555,13 @@ describe('TokenAccessController', function () { ctx.req.body = { tokenHashPrefix: '#prefix' } }) describe('ANONYMOUS_READ_AND_WRITE_ENABLED is undefined', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise((resolve, reject) => { ctx.res.callback = resolve ctx.TokenAccessController.grantTokenAccessReadAndWrite( ctx.req, ctx.res, - resolve + ctx.rejectOnError(reject) ) }) }) @@ -595,15 +595,15 @@ describe('TokenAccessController', function () { }) describe('ANONYMOUS_READ_AND_WRITE_ENABLED is true', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { - ctx.TokenAccessHandler.ANONYMOUS_READ_AND_WRITE_ENABLED = true + beforeEach(async function (ctx) { + ctx.TokenAccessHandler.ANONYMOUS_READ_AND_WRITE_ENABLED = true + await new Promise((resolve, reject) => { ctx.res.callback = resolve ctx.TokenAccessController.grantTokenAccessReadAndWrite( ctx.req, ctx.res, - resolve + ctx.rejectOnError(reject) ) }) }) @@ -637,22 +637,20 @@ describe('TokenAccessController', function () { ctx.Settings.overleaf = {} }) describe('when token is for v1 project', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { - ctx.TokenAccessHandler.promises.getProjectByToken.resolves( - undefined - ) - ctx.TokenAccessHandler.promises.getV1DocInfo.resolves({ - exists: true, - has_owner: true, - }) - ctx.req.params = { token: ctx.token } - ctx.req.body = { tokenHashPrefix: '#prefix' } + beforeEach(async function (ctx) { + ctx.TokenAccessHandler.promises.getProjectByToken.resolves(undefined) + ctx.TokenAccessHandler.promises.getV1DocInfo.resolves({ + exists: true, + has_owner: true, + }) + ctx.req.params = { token: ctx.token } + ctx.req.body = { tokenHashPrefix: '#prefix' } + await new Promise((resolve, reject) => { ctx.res.callback = resolve ctx.TokenAccessController.grantTokenAccessReadAndWrite( ctx.req, ctx.res, - resolve + ctx.rejectOnError(reject) ) }) }) @@ -683,21 +681,19 @@ describe('TokenAccessController', function () { }) describe('when token is not for a v1 or v2 project', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { - ctx.TokenAccessHandler.promises.getProjectByToken.resolves( - undefined - ) - ctx.TokenAccessHandler.promises.getV1DocInfo.resolves({ - exists: false, - }) - ctx.req.params = { token: ctx.token } - ctx.req.body = { tokenHashPrefix: '#prefix' } + beforeEach(async function (ctx) { + ctx.TokenAccessHandler.promises.getProjectByToken.resolves(undefined) + ctx.TokenAccessHandler.promises.getV1DocInfo.resolves({ + exists: false, + }) + ctx.req.params = { token: ctx.token } + ctx.req.body = { tokenHashPrefix: '#prefix' } + await new Promise((resolve, reject) => { ctx.res.callback = resolve ctx.TokenAccessController.grantTokenAccessReadAndWrite( ctx.req, ctx.res, - resolve + ctx.rejectOnError(reject) ) }) }) @@ -726,8 +722,8 @@ describe('TokenAccessController', function () { ctx.req.params = { token: ctx.token } ctx.req.body = { tokenHashPrefix: '#prefix' } }) - it('passes Errors.NotFoundError to next when project not found and still checks token hash', function (ctx) { - return new Promise(resolve => { + it('passes Errors.NotFoundError to next when project not found and still checks token hash', async function (ctx) { + await new Promise(resolve => { ctx.TokenAccessController.grantTokenAccessReadAndWrite( ctx.req, ctx.res, @@ -763,11 +759,12 @@ describe('TokenAccessController', function () { ctx.req.body = { confirmedByUser: true, tokenHashPrefix: '#prefix' } }) - it('redirects if project owner is non-admin', function (ctx) { + it('redirects if project owner is non-admin', async function (ctx) { ctx.UserGetter.promises.getUserConfirmedEmails = sinon .stub() .resolves([{ email: 'test@not-overleaf.com' }]) - return new Promise(resolve => { + + await new Promise(resolve => { ctx.res.callback = () => { expect(ctx.res.json).to.have.been.calledWith({ redirect: `${ctx.Settings.adminUrl}/#prefix`, @@ -811,8 +808,8 @@ describe('TokenAccessController', function () { }) }) - it('passes Errors.NotFoundError to next when token access is not enabled but still checks token hash', function (ctx) { - return new Promise(resolve => { + it('passes Errors.NotFoundError to next when token access is not enabled but still checks token hash', async function (ctx) { + await new Promise(resolve => { ctx.TokenAccessHandler.tokenAccessEnabledForProject.returns(false) ctx.req.params = { token: ctx.token } ctx.req.body = { tokenHashPrefix: '#prefix' } @@ -852,15 +849,15 @@ describe('TokenAccessController', function () { describe('grantTokenAccessReadOnly', function () { describe('normal case', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { - ctx.req.params = { token: ctx.token } - ctx.req.body = { confirmedByUser: true, tokenHashPrefix: '#prefix' } + beforeEach(async function (ctx) { + ctx.req.params = { token: ctx.token } + ctx.req.body = { confirmedByUser: true, tokenHashPrefix: '#prefix' } + await new Promise((resolve, reject) => { ctx.res.callback = resolve ctx.TokenAccessController.grantTokenAccessReadOnly( ctx.req, ctx.res, - resolve + ctx.rejectOnError(reject) ) }) }) @@ -901,16 +898,16 @@ describe('TokenAccessController', function () { }) describe('when the access was already granted', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { - ctx.project.tokenAccessReadOnly_refs.push(ctx.user._id) - ctx.req.params = { token: ctx.token } - ctx.req.body = { confirmedByUser: true } + beforeEach(async function (ctx) { + ctx.project.tokenAccessReadOnly_refs.push(ctx.user._id) + ctx.req.params = { token: ctx.token } + ctx.req.body = { confirmedByUser: true } + await new Promise((resolve, reject) => { ctx.res.callback = resolve ctx.TokenAccessController.grantTokenAccessReadOnly( ctx.req, ctx.res, - resolve + ctx.rejectOnError(reject) ) }) }) @@ -942,16 +939,16 @@ describe('TokenAccessController', function () { }) describe('anonymous users', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { - ctx.req.params = { token: ctx.token } - ctx.SessionManager.getLoggedInUserId.returns(null) + beforeEach(async function (ctx) { + ctx.req.params = { token: ctx.token } + ctx.SessionManager.getLoggedInUserId.returns(null) + await new Promise((resolve, reject) => { ctx.res.callback = resolve ctx.TokenAccessController.grantTokenAccessReadOnly( ctx.req, ctx.res, - resolve + ctx.rejectOnError(reject) ) }) }) @@ -972,18 +969,18 @@ describe('TokenAccessController', function () { }) describe('user is owner of project', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { - ctx.AuthorizationManager.promises.getPrivilegeLevelForProject.returns( - PrivilegeLevels.OWNER - ) - ctx.req.params = { token: ctx.token } - ctx.req.body = {} + beforeEach(async function (ctx) { + ctx.AuthorizationManager.promises.getPrivilegeLevelForProject.returns( + PrivilegeLevels.OWNER + ) + ctx.req.params = { token: ctx.token } + ctx.req.body = {} + await new Promise((resolve, reject) => { ctx.res.callback = resolve ctx.TokenAccessController.grantTokenAccessReadOnly( ctx.req, ctx.res, - resolve + ctx.rejectOnError(reject) ) }) }) @@ -1003,8 +1000,8 @@ describe('TokenAccessController', function () { }) }) - it('passes Errors.NotFoundError to next when token access is not enabled but still checks token hash', function (ctx) { - return new Promise(resolve => { + it('passes Errors.NotFoundError to next when token access is not enabled but still checks token hash', async function (ctx) { + await new Promise(resolve => { ctx.TokenAccessHandler.tokenAccessEnabledForProject.returns(false) ctx.req.params = { token: ctx.token } ctx.req.body = { tokenHashPrefix: '#prefix' } @@ -1040,13 +1037,13 @@ describe('TokenAccessController', function () { }) describe('when not in link sharing changes test', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise((resolve, reject) => { ctx.AsyncFormHelper.redirect = sinon.stub().callsFake(() => resolve()) ctx.TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage( ctx.req, ctx.res, - resolve + ctx.rejectOnError(reject) ) }) }) @@ -1068,8 +1065,8 @@ describe('TokenAccessController', function () { }) describe('when user is not an invited editor and is a read write token member', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject.resolves( false ) @@ -1099,18 +1096,18 @@ describe('TokenAccessController', function () { }) describe('when user is already an invited editor', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { - ctx.CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject.resolves( - true - ) + beforeEach(async function (ctx) { + ctx.CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject.resolves( + true + ) + await new Promise((resolve, reject) => { ctx.AsyncFormHelper.redirect = sinon .stub() .callsFake(() => resolve()) ctx.TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage( ctx.req, ctx.res, - resolve + ctx.rejectOnError(reject) ) }) }) @@ -1125,18 +1122,18 @@ describe('TokenAccessController', function () { }) describe('when user not a read write token member', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { - ctx.CollaboratorsGetter.promises.userIsReadWriteTokenMember.resolves( - false - ) + beforeEach(async function (ctx) { + ctx.CollaboratorsGetter.promises.userIsReadWriteTokenMember.resolves( + false + ) + await new Promise((resolve, reject) => { ctx.AsyncFormHelper.redirect = sinon .stub() .callsFake(() => resolve()) ctx.TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage( ctx.req, ctx.res, - resolve + ctx.rejectOnError(reject) ) }) }) @@ -1165,16 +1162,16 @@ describe('TokenAccessController', function () { }) describe('previously joined token access user moving to named collaborator', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { - ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves( - false - ) + beforeEach(async function (ctx) { + ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves( + false + ) + await new Promise((resolve, reject) => { ctx.res.callback = resolve ctx.TokenAccessController.moveReadWriteToCollaborators( ctx.req, ctx.res, - resolve + ctx.rejectOnError(reject) ) }) }) @@ -1204,16 +1201,16 @@ describe('TokenAccessController', function () { }) describe('previously joined token access user moving to named collaborator', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { - ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves( - false - ) + beforeEach(async function (ctx) { + ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves( + false + ) + await new Promise((resolve, reject) => { ctx.res.callback = resolve ctx.TokenAccessController.moveReadWriteToCollaborators( ctx.req, ctx.res, - resolve + ctx.rejectOnError(reject) ) }) }) @@ -1243,13 +1240,13 @@ describe('TokenAccessController', function () { }) describe('previously joined token access user moving to anonymous viewer', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise((resolve, reject) => { ctx.res.callback = resolve ctx.TokenAccessController.moveReadWriteToReadOnly( ctx.req, ctx.res, - resolve + ctx.rejectOnError(reject) ) }) }) diff --git a/services/web/test/unit/src/Uploads/ProjectUploadController.test.mjs b/services/web/test/unit/src/Uploads/ProjectUploadController.test.mjs index 443578f747..b36d596da2 100644 --- a/services/web/test/unit/src/Uploads/ProjectUploadController.test.mjs +++ b/services/web/test/unit/src/Uploads/ProjectUploadController.test.mjs @@ -264,8 +264,8 @@ describe('ProjectUploadController', function () { }) describe('with folder structure', function () { - beforeEach(function (ctx) { - return new Promise(resolve => { + beforeEach(async function (ctx) { + await new Promise(resolve => { ctx.entity = { _id: '1234', type: 'file', diff --git a/services/web/test/unit/src/User/UserEmailsControllerTests.js b/services/web/test/unit/src/User/UserEmailsControllerTests.js index ae94194084..2cd7ad491f 100644 --- a/services/web/test/unit/src/User/UserEmailsControllerTests.js +++ b/services/web/test/unit/src/User/UserEmailsControllerTests.js @@ -101,11 +101,6 @@ describe('UserEmailsController', function () { '../Analytics/AnalyticsManager': this.AnalyticsManager, './UserAuditLogHandler': this.UserAuditLogHandler, '../../infrastructure/RateLimiter': this.RateLimiter, - '../SplitTests/SplitTestHandler': { - promises: { - getAssignment: sinon.stub().resolves('default'), - }, - }, }, }) }) @@ -910,7 +905,7 @@ describe('UserEmailsController', function () { }) }) - describe('sendExistingSecondaryEmailConfirmationCode', function () { + describe('sendExistingEmailConfirmationCode', function () { beforeEach(function () { this.email = 'existing-email@example.com' this.req.body.email = this.email @@ -928,7 +923,7 @@ describe('UserEmailsController', function () { }) it('should send confirmation code for existing email', async function () { - await this.UserEmailsController.sendExistingSecondaryEmailConfirmationCode( + await this.UserEmailsController.sendExistingEmailConfirmationCode( this.req, { sendStatus: code => { @@ -949,7 +944,7 @@ describe('UserEmailsController', function () { this.UserEmailsConfirmationHandler.promises.sendConfirmationCode.resolves( { confirmCode, confirmCodeExpiresTimestamp } ) - await this.UserEmailsController.sendExistingSecondaryEmailConfirmationCode( + await this.UserEmailsController.sendExistingEmailConfirmationCode( this.req, { sendStatus: sinon.stub() } ) @@ -963,7 +958,7 @@ describe('UserEmailsController', function () { it('should handle invalid email', async function () { this.EmailHelper.parseEmail.returns(null) - await this.UserEmailsController.sendExistingSecondaryEmailConfirmationCode( + await this.UserEmailsController.sendExistingEmailConfirmationCode( this.req, { sendStatus: code => { @@ -980,7 +975,7 @@ describe('UserEmailsController', function () { this.UserGetter.promises.getUserByAnyEmail.resolves({ _id: 'another-user-id', }) - await this.UserEmailsController.sendExistingSecondaryEmailConfirmationCode( + await this.UserEmailsController.sendExistingEmailConfirmationCode( this.req, { sendStatus: code => { diff --git a/services/web/test/unit/src/User/UserPagesController.test.mjs b/services/web/test/unit/src/User/UserPagesController.test.mjs index 1fa908d1be..0a05cbfb3c 100644 --- a/services/web/test/unit/src/User/UserPagesController.test.mjs +++ b/services/web/test/unit/src/User/UserPagesController.test.mjs @@ -155,28 +155,32 @@ describe('UserPagesController', function () { ctx.UserPagesController = (await import(modulePath)).default ctx.req = new MockRequest() + ctx.req.capabilitySet = new Set() ctx.req.session.user = ctx.user ctx.res = new MockResponse() }) describe('registerPage', function () { - it('should render the register page', function (ctx) { - return new Promise(resolve => { + it('should render the register page', async function (ctx) { + await new Promise((resolve, reject) => { ctx.res.callback = () => { ctx.res.renderedTemplate.should.equal('user/register') resolve() } - ctx.UserPagesController.registerPage(ctx.req, ctx.res, resolve) + ctx.UserPagesController.registerPage( + ctx.req, + ctx.res, + ctx.rejectOnError(reject) + ) }) }) - it('should set sharedProjectData', function (ctx) { - return new Promise(resolve => { - ctx.req.session.sharedProjectData = { - project_name: 'myProject', - user_first_name: 'user_first_name_here', - } - + it('should set sharedProjectData', async function (ctx) { + ctx.req.session.sharedProjectData = { + project_name: 'myProject', + user_first_name: 'user_first_name_here', + } + await new Promise((resolve, reject) => { ctx.res.callback = () => { ctx.res.renderedVariables.sharedProjectData.project_name.should.equal( 'myProject' @@ -186,12 +190,16 @@ describe('UserPagesController', function () { ) resolve() } - ctx.UserPagesController.registerPage(ctx.req, ctx.res, resolve) + ctx.UserPagesController.registerPage( + ctx.req, + ctx.res, + ctx.rejectOnError(reject) + ) }) }) - it('should set newTemplateData', function (ctx) { - return new Promise(resolve => { + it('should set newTemplateData', async function (ctx) { + await new Promise((resolve, reject) => { ctx.req.session.templateData = { templateName: 'templateName' } ctx.res.callback = () => { @@ -200,12 +208,16 @@ describe('UserPagesController', function () { ) resolve() } - ctx.UserPagesController.registerPage(ctx.req, ctx.res, resolve) + ctx.UserPagesController.registerPage( + ctx.req, + ctx.res, + ctx.rejectOnError(reject) + ) }) }) - it('should not set the newTemplateData if there is nothing in the session', function (ctx) { - return new Promise(resolve => { + it('should not set the newTemplateData if there is nothing in the session', async function (ctx) { + await new Promise((resolve, reject) => { ctx.res.callback = () => { assert.equal( ctx.res.renderedVariables.newTemplateData.templateName, @@ -213,19 +225,27 @@ describe('UserPagesController', function () { ) resolve() } - ctx.UserPagesController.registerPage(ctx.req, ctx.res, resolve) + ctx.UserPagesController.registerPage( + ctx.req, + ctx.res, + ctx.rejectOnError(reject) + ) }) }) }) describe('loginForm', function () { - it('should render the login page', function (ctx) { - return new Promise(resolve => { + it('should render the login page', async function (ctx) { + await new Promise((resolve, reject) => { ctx.res.callback = () => { ctx.res.renderedTemplate.should.equal('user/login') resolve() } - ctx.UserPagesController.loginPage(ctx.req, ctx.res, resolve) + ctx.UserPagesController.loginPage( + ctx.req, + ctx.res, + ctx.rejectOnError(reject) + ) }) }) @@ -238,8 +258,8 @@ describe('UserPagesController', function () { ctx.req.query.redir = '/somewhere/in/particular' }) - it('should set a redirect', function (ctx) { - return new Promise(resolve => { + it('should set a redirect', async function (ctx) { + await new Promise((resolve, reject) => { ctx.res.callback = page => { ctx.AuthenticationController.setRedirectInSession.callCount.should.equal( 1 @@ -249,7 +269,11 @@ describe('UserPagesController', function () { ).to.equal(ctx.req.query.redir) resolve() } - ctx.UserPagesController.loginPage(ctx.req, ctx.res, resolve) + ctx.UserPagesController.loginPage( + ctx.req, + ctx.res, + ctx.rejectOnError(reject) + ) }) }) }) @@ -260,18 +284,22 @@ describe('UserPagesController', function () { ctx.UserSessionsManager.getAllUserSessions.callsArgWith(2, null, []) }) - it('should render user/sessions', function (ctx) { - return new Promise(resolve => { + it('should render user/sessions', async function (ctx) { + await new Promise((resolve, reject) => { ctx.res.callback = () => { ctx.res.renderedTemplate.should.equal('user/sessions') resolve() } - ctx.UserPagesController.sessionsPage(ctx.req, ctx.res, resolve) + ctx.UserPagesController.sessionsPage( + ctx.req, + ctx.res, + ctx.rejectOnError(reject) + ) }) }) - it('should include current session data in the view', function (ctx) { - return new Promise(resolve => { + it('should include current session data in the view', async function (ctx) { + await new Promise((resolve, reject) => { ctx.res.callback = () => { expect(ctx.res.renderedVariables.currentSession).to.deep.equal({ ip_address: '1.1.1.1', @@ -279,17 +307,25 @@ describe('UserPagesController', function () { }) resolve() } - ctx.UserPagesController.sessionsPage(ctx.req, ctx.res, resolve) + ctx.UserPagesController.sessionsPage( + ctx.req, + ctx.res, + ctx.rejectOnError(reject) + ) }) }) - it('should have called getAllUserSessions', function (ctx) { - return new Promise(resolve => { + it('should have called getAllUserSessions', async function (ctx) { + await new Promise((resolve, reject) => { ctx.res.callback = page => { ctx.UserSessionsManager.getAllUserSessions.callCount.should.equal(1) resolve() } - ctx.UserPagesController.sessionsPage(ctx.req, ctx.res, resolve) + ctx.UserPagesController.sessionsPage( + ctx.req, + ctx.res, + ctx.rejectOnError(reject) + ) }) }) @@ -301,8 +337,8 @@ describe('UserPagesController', function () { ) }) - it('should call next with an error', function (ctx) { - return new Promise(resolve => { + it('should call next with an error', async function (ctx) { + await new Promise(resolve => { ctx.next = err => { assert(err !== null) assert(err instanceof Error) @@ -319,29 +355,37 @@ describe('UserPagesController', function () { ctx.UserGetter.getUser = sinon.stub().yields(null, ctx.user) }) - it('render page with subscribed status', function (ctx) { - return new Promise(resolve => { - ctx.NewsletterManager.subscribed.yields(null, true) + it('render page with subscribed status', async function (ctx) { + ctx.NewsletterManager.subscribed.yields(null, true) + await new Promise((resolve, reject) => { ctx.res.callback = () => { ctx.res.renderedTemplate.should.equal('user/email-preferences') ctx.res.renderedVariables.title.should.equal('newsletter_info_title') ctx.res.renderedVariables.subscribed.should.equal(true) resolve() } - ctx.UserPagesController.emailPreferencesPage(ctx.req, ctx.res, resolve) + ctx.UserPagesController.emailPreferencesPage( + ctx.req, + ctx.res, + ctx.rejectOnError(reject) + ) }) }) - it('render page with unsubscribed status', function (ctx) { - return new Promise(resolve => { - ctx.NewsletterManager.subscribed.yields(null, false) + it('render page with unsubscribed status', async function (ctx) { + ctx.NewsletterManager.subscribed.yields(null, false) + await new Promise((resolve, reject) => { ctx.res.callback = () => { ctx.res.renderedTemplate.should.equal('user/email-preferences') ctx.res.renderedVariables.title.should.equal('newsletter_info_title') ctx.res.renderedVariables.subscribed.should.equal(false) resolve() } - ctx.UserPagesController.emailPreferencesPage(ctx.req, ctx.res, resolve) + ctx.UserPagesController.emailPreferencesPage( + ctx.req, + ctx.res, + ctx.rejectOnError(reject) + ) }) }) }) @@ -354,55 +398,71 @@ describe('UserPagesController', function () { ctx.UserGetter.promises.getUser = sinon.stub().resolves(ctx.user) }) - it('should render user/settings', function (ctx) { - return new Promise(resolve => { + it('should render user/settings', async function (ctx) { + await new Promise((resolve, reject) => { ctx.res.callback = () => { ctx.res.renderedTemplate.should.equal('user/settings') resolve() } - ctx.UserPagesController.settingsPage(ctx.req, ctx.res, resolve) + ctx.UserPagesController.settingsPage( + ctx.req, + ctx.res, + ctx.rejectOnError(reject) + ) }) }) - it('should send user', function (ctx) { - return new Promise(resolve => { + it('should send user', async function (ctx) { + await new Promise((resolve, reject) => { ctx.res.callback = () => { ctx.res.renderedVariables.user.id.should.equal(ctx.user._id) ctx.res.renderedVariables.user.email.should.equal(ctx.user.email) resolve() } - ctx.UserPagesController.settingsPage(ctx.req, ctx.res, resolve) + ctx.UserPagesController.settingsPage( + ctx.req, + ctx.res, + ctx.rejectOnError(reject) + ) }) }) - it("should set 'shouldAllowEditingDetails' to true", function (ctx) { - return new Promise(resolve => { + it("should set 'shouldAllowEditingDetails' to true", async function (ctx) { + await new Promise((resolve, reject) => { ctx.res.callback = () => { ctx.res.renderedVariables.shouldAllowEditingDetails.should.equal(true) resolve() } - ctx.UserPagesController.settingsPage(ctx.req, ctx.res, resolve) + ctx.UserPagesController.settingsPage( + ctx.req, + ctx.res, + ctx.rejectOnError(reject) + ) }) }) - it('should restructure thirdPartyIdentifiers data for template use', function (ctx) { - return new Promise(resolve => { - const expectedResult = { - google: 'testId', - } + it('should restructure thirdPartyIdentifiers data for template use', async function (ctx) { + const expectedResult = { + google: 'testId', + } + await new Promise((resolve, reject) => { ctx.res.callback = () => { expect(ctx.res.renderedVariables.thirdPartyIds).to.include( expectedResult ) resolve() } - ctx.UserPagesController.settingsPage(ctx.req, ctx.res, resolve) + ctx.UserPagesController.settingsPage( + ctx.req, + ctx.res, + ctx.rejectOnError(reject) + ) }) }) - it("should set and clear 'projectSyncSuccessMessage'", function (ctx) { - return new Promise(resolve => { - ctx.req.session.projectSyncSuccessMessage = 'Some Sync Success' + it("should set and clear 'projectSyncSuccessMessage'", async function (ctx) { + ctx.req.session.projectSyncSuccessMessage = 'Some Sync Success' + await new Promise((resolve, reject) => { ctx.res.callback = () => { ctx.res.renderedVariables.projectSyncSuccessMessage.should.equal( 'Some Sync Success' @@ -410,12 +470,16 @@ describe('UserPagesController', function () { expect(ctx.req.session.projectSyncSuccessMessage).to.not.exist resolve() } - ctx.UserPagesController.settingsPage(ctx.req, ctx.res, resolve) + ctx.UserPagesController.settingsPage( + ctx.req, + ctx.res, + ctx.rejectOnError(reject) + ) }) }) - it('should cast refProviders to booleans', function (ctx) { - return new Promise(resolve => { + it('should cast refProviders to booleans', async function (ctx) { + await new Promise((resolve, reject) => { ctx.res.callback = () => { expect(ctx.res.renderedVariables.user.refProviders).to.deep.equal({ mendeley: true, @@ -424,53 +488,60 @@ describe('UserPagesController', function () { }) resolve() } - ctx.UserPagesController.settingsPage(ctx.req, ctx.res, resolve) + ctx.UserPagesController.settingsPage( + ctx.req, + ctx.res, + ctx.rejectOnError(reject) + ) }) }) - it('should send the correct managed user admin email', function (ctx) { - return new Promise(resolve => { + it('should send the correct managed user admin email', async function (ctx) { + await new Promise((resolve, reject) => { ctx.res.callback = () => { expect( ctx.res.renderedVariables.currentManagedUserAdminEmail ).to.equal(ctx.adminEmail) resolve() } - ctx.UserPagesController.settingsPage(ctx.req, ctx.res, resolve) + ctx.UserPagesController.settingsPage( + ctx.req, + ctx.res, + ctx.rejectOnError(reject) + ) }) }) - it('should send info for groups with SSO enabled', function (ctx) { - return new Promise(resolve => { - ctx.user.enrollment = { - sso: [ - { - groupId: 'abc123abc123', - primary: true, - linkedAt: new Date(), - }, - ], - } - const group1 = { - _id: 'abc123abc123', - teamName: 'Group SSO Rulz', - admin_id: { - email: 'admin.email@ssolove.com', + it('should send info for groups with SSO enabled', async function (ctx) { + ctx.user.enrollment = { + sso: [ + { + groupId: 'abc123abc123', + primary: true, + linkedAt: new Date(), }, - linked: true, - } - const group2 = { - _id: 'def456def456', - admin_id: { - email: 'someone.else@noname.co.uk', - }, - linked: false, - } - - ctx.Modules.promises.hooks.fire - .withArgs('getUserGroupsSSOEnrollmentStatus') - .resolves([[group1, group2]]) + ], + } + const group1 = { + _id: 'abc123abc123', + teamName: 'Group SSO Rulz', + admin_id: { + email: 'admin.email@ssolove.com', + }, + linked: true, + } + const group2 = { + _id: 'def456def456', + admin_id: { + email: 'someone.else@noname.co.uk', + }, + linked: false, + } + ctx.Modules.promises.hooks.fire + .withArgs('getUserGroupsSSOEnrollmentStatus') + .resolves([[group1, group2]]) + await new Promise((resolve, reject) => { ctx.res.callback = () => { expect( ctx.res.renderedVariables.memberOfSSOEnabledGroups @@ -491,7 +562,11 @@ describe('UserPagesController', function () { resolve() } - ctx.UserPagesController.settingsPage(ctx.req, ctx.res, resolve) + ctx.UserPagesController.settingsPage( + ctx.req, + ctx.res, + ctx.rejectOnError(reject) + ) }) }) @@ -504,15 +579,19 @@ describe('UserPagesController', function () { delete ctx.settings.ldap }) - it('should set "shouldAllowEditingDetails" to false', function (ctx) { - return new Promise(resolve => { + it('should set "shouldAllowEditingDetails" to false', async function (ctx) { + await new Promise((resolve, reject) => { ctx.res.callback = () => { ctx.res.renderedVariables.shouldAllowEditingDetails.should.equal( false ) resolve() } - ctx.UserPagesController.settingsPage(ctx.req, ctx.res, resolve) + ctx.UserPagesController.settingsPage( + ctx.req, + ctx.res, + ctx.rejectOnError(reject) + ) }) }) }) @@ -526,15 +605,19 @@ describe('UserPagesController', function () { delete ctx.settings.saml }) - it('should set "shouldAllowEditingDetails" to false', function (ctx) { - return new Promise(resolve => { + it('should set "shouldAllowEditingDetails" to false', async function (ctx) { + await new Promise((resolve, reject) => { ctx.res.callback = () => { ctx.res.renderedVariables.shouldAllowEditingDetails.should.equal( false ) resolve() } - ctx.UserPagesController.settingsPage(ctx.req, ctx.res, resolve) + ctx.UserPagesController.settingsPage( + ctx.req, + ctx.res, + ctx.rejectOnError(reject) + ) }) }) }) diff --git a/services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs b/services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs index 18e2d8526b..1726b2a41b 100644 --- a/services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs +++ b/services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs @@ -59,6 +59,48 @@ describe('UserMembershipController', function () { last_logged_in_at: '2020-05-20T10:41:11.407Z', last_active_at: '2021-05-20T10:41:11.407Z', }, + { + _id: 'mock-member-id-3', + email: 'mock-email-3@foo.com', + last_logged_in_at: '2021-08-10T10:41:11.407Z', + last_active_at: '2021-08-20T10:41:11.407Z', + enrollment: { + managedBy: 'some-other-subscription-id', + enrolledAt: '2021-05-20T10:41:11.407Z', + sso: undefined, + }, + }, + { + _id: 'mock-member-id-4', + email: 'mock-email-4@foo.com', + last_logged_in_at: '2021-01-01T10:41:11.407Z', + last_active_at: '2021-01-02T10:41:11.407Z', + enrollment: { + managedBy: 'mock-subscription-id', + enrolledAt: '2021-01-02T10:41:11.407Z', + sso: undefined, + }, + }, + { + _id: 'mock-member-id-5', + email: 'mock-email-5@foo.com', + last_logged_in_at: '2023-01-01T10:41:11.407Z', + last_active_at: '2023-01-02T10:41:11.407Z', + enrollment: { + sso: [{ groupId: ctx.subscription._id }], + }, + }, + { + _id: 'mock-member-id-6', + email: 'mock-email-6@foo.com', + last_logged_in_at: '2024-01-01T10:41:11.407Z', + last_active_at: '2024-01-02T10:41:11.407Z', + enrollment: { + managedBy: 'mock-subscription-id', + enrolledAt: '2024-01-02T10:41:11.407Z', + sso: [{ groupId: ctx.subscription._id }], + }, + }, ] ctx.Settings = { @@ -143,6 +185,17 @@ describe('UserMembershipController', function () { SSOConfig: ctx.SSOConfig, })) + ctx.Modules = { + promises: { + hooks: { + fire: sinon.stub(), + }, + }, + } + vi.doMock('../../../../app/src/infrastructure/Modules.js', () => ({ + default: ctx.Modules, + })) + ctx.UserMembershipController = (await import(modulePath)).default }) @@ -221,8 +274,8 @@ describe('UserMembershipController', function () { ctx.req.entityConfig = EntityConfigs.groupManagers }) - it('add user', function (ctx) { - return new Promise(resolve => { + it('add user', async function (ctx) { + await new Promise(resolve => { ctx.UserMembershipController.add(ctx.req, { json: () => { sinon.assert.calledWithMatch( @@ -237,8 +290,8 @@ describe('UserMembershipController', function () { }) }) - it('return user object', function (ctx) { - return new Promise(resolve => { + it('return user object', async function (ctx) { + await new Promise(resolve => { ctx.UserMembershipController.add(ctx.req, { json: payload => { payload.user.should.equal(ctx.newUser) @@ -248,8 +301,8 @@ describe('UserMembershipController', function () { }) }) - it('handle readOnly entity', function (ctx) { - return new Promise(resolve => { + it('handle readOnly entity', async function (ctx) { + await new Promise(resolve => { ctx.req.entityConfig = EntityConfigs.group ctx.UserMembershipController.add(ctx.req, null, error => { expect(error).to.exist @@ -259,8 +312,8 @@ describe('UserMembershipController', function () { }) }) - it('handle user already added', function (ctx) { - return new Promise(resolve => { + it('handle user already added', async function (ctx) { + await new Promise(resolve => { ctx.UserMembershipHandler.addUser.yields(new UserAlreadyAddedError()) ctx.UserMembershipController.add(ctx.req, { status: () => ({ @@ -273,8 +326,8 @@ describe('UserMembershipController', function () { }) }) - it('handle user not found', function (ctx) { - return new Promise(resolve => { + it('handle user not found', async function (ctx) { + await new Promise(resolve => { ctx.UserMembershipHandler.addUser.yields(new UserNotFoundError()) ctx.UserMembershipController.add(ctx.req, { status: () => ({ @@ -287,8 +340,8 @@ describe('UserMembershipController', function () { }) }) - it('handle invalid email', function (ctx) { - return new Promise(resolve => { + it('handle invalid email', async function (ctx) { + await new Promise(resolve => { ctx.req.body.email = 'not_valid_email' ctx.UserMembershipController.add(ctx.req, { status: () => ({ @@ -309,8 +362,8 @@ describe('UserMembershipController', function () { ctx.req.entityConfig = EntityConfigs.groupManagers }) - it('remove user', function (ctx) { - return new Promise(resolve => { + it('remove user', async function (ctx) { + await new Promise(resolve => { ctx.UserMembershipController.remove(ctx.req, { sendStatus: () => { sinon.assert.calledWithMatch( @@ -325,8 +378,8 @@ describe('UserMembershipController', function () { }) }) - it('handle readOnly entity', function (ctx) { - return new Promise(resolve => { + it('handle readOnly entity', async function (ctx) { + await new Promise(resolve => { ctx.req.entityConfig = EntityConfigs.group ctx.UserMembershipController.remove(ctx.req, null, error => { expect(error).to.exist @@ -336,8 +389,8 @@ describe('UserMembershipController', function () { }) }) - it('prevent self removal', function (ctx) { - return new Promise(resolve => { + it('prevent self removal', async function (ctx) { + await new Promise(resolve => { ctx.req.params.userId = ctx.user._id ctx.UserMembershipController.remove(ctx.req, { status: () => ({ @@ -350,8 +403,8 @@ describe('UserMembershipController', function () { }) }) - it('prevent admin removal', function (ctx) { - return new Promise(resolve => { + it('prevent admin removal', async function (ctx) { + await new Promise(resolve => { ctx.UserMembershipHandler.removeUser.yields(new UserIsManagerError()) ctx.UserMembershipController.remove(ctx.req, { status: () => ({ @@ -377,7 +430,7 @@ describe('UserMembershipController', function () { it('get users', function (ctx) { sinon.assert.calledWithMatch( - ctx.UserMembershipHandler.getUsers, + ctx.UserMembershipHandler.promises.getUsers, ctx.subscription, { modelName: 'Subscription' } ) @@ -398,7 +451,67 @@ describe('UserMembershipController', function () { it('should export the correct csv', function (ctx) { assertCalledWith( ctx.res.send, - '"email","last_logged_in_at","last_active_at"\n"mock-email-1@foo.com","2020-08-09T12:43:11.467Z","2021-08-09T12:43:11.467Z"\n"mock-email-2@foo.com","2020-05-20T10:41:11.407Z","2021-05-20T10:41:11.407Z"' + '"email","last_logged_in_at","last_active_at"\n"mock-email-1@foo.com","2020-08-09T12:43:11.467Z","2021-08-09T12:43:11.467Z"\n"mock-email-2@foo.com","2020-05-20T10:41:11.407Z","2021-05-20T10:41:11.407Z"\n"mock-email-3@foo.com","2021-08-10T10:41:11.407Z","2021-08-20T10:41:11.407Z"\n"mock-email-4@foo.com","2021-01-01T10:41:11.407Z","2021-01-02T10:41:11.407Z"\n"mock-email-5@foo.com","2023-01-01T10:41:11.407Z","2023-01-02T10:41:11.407Z"\n"mock-email-6@foo.com","2024-01-01T10:41:11.407Z","2024-01-02T10:41:11.407Z"' + ) + }) + }) + + describe('exportCsv when group is managed', function () { + beforeEach(function (ctx) { + ctx.req.entity = Object.assign( + { managedUsersEnabled: true }, + ctx.subscription + ) + ctx.req.entityConfig = EntityConfigs.groupManagers + ctx.res = new MockResponse() + ctx.UserMembershipController.exportCsv(ctx.req, ctx.res) + }) + + it('should export the correct csv', function (ctx) { + assertCalledWith( + ctx.res.send, + '"email","last_logged_in_at","last_active_at","managed"\n"mock-email-1@foo.com","2020-08-09T12:43:11.467Z","2021-08-09T12:43:11.467Z",false\n"mock-email-2@foo.com","2020-05-20T10:41:11.407Z","2021-05-20T10:41:11.407Z",false\n"mock-email-3@foo.com","2021-08-10T10:41:11.407Z","2021-08-20T10:41:11.407Z",false\n"mock-email-4@foo.com","2021-01-01T10:41:11.407Z","2021-01-02T10:41:11.407Z",true\n"mock-email-5@foo.com","2023-01-01T10:41:11.407Z","2023-01-02T10:41:11.407Z",false\n"mock-email-6@foo.com","2024-01-01T10:41:11.407Z","2024-01-02T10:41:11.407Z",true' + ) + }) + }) + + describe('exportCsv when group has SSO', function () { + beforeEach(function (ctx) { + ctx.req.entity = Object.assign( + { ssoConfig: 'sso-config-id' }, + ctx.subscription + ) + ctx.req.entityConfig = EntityConfigs.groupManagers + ctx.Modules.promises.hooks.fire.resolves([true]) + ctx.res = new MockResponse() + ctx.UserMembershipController.exportCsv(ctx.req, ctx.res) + }) + + it('should export the correct csv', function (ctx) { + assertCalledWith( + ctx.res.send, + '"email","last_logged_in_at","last_active_at","sso"\n"mock-email-1@foo.com","2020-08-09T12:43:11.467Z","2021-08-09T12:43:11.467Z",false\n"mock-email-2@foo.com","2020-05-20T10:41:11.407Z","2021-05-20T10:41:11.407Z",false\n"mock-email-3@foo.com","2021-08-10T10:41:11.407Z","2021-08-20T10:41:11.407Z",false\n"mock-email-4@foo.com","2021-01-01T10:41:11.407Z","2021-01-02T10:41:11.407Z",false\n"mock-email-5@foo.com","2023-01-01T10:41:11.407Z","2023-01-02T10:41:11.407Z",true\n"mock-email-6@foo.com","2024-01-01T10:41:11.407Z","2024-01-02T10:41:11.407Z",true' + ) + }) + }) + + describe('exportCsv when group has SSO and managed users enabled', function () { + beforeEach(function (ctx) { + ctx.req.entity = Object.assign( + { managedUsersEnabled: true }, + { ssoConfig: 'sso-config-id' }, + ctx.subscription + ) + ctx.req.entityConfig = EntityConfigs.groupManagers + ctx.Modules.promises.hooks.fire.resolves([true]) + ctx.res = new MockResponse() + ctx.UserMembershipController.exportCsv(ctx.req, ctx.res) + }) + + it('should export the correct csv', function (ctx) { + assertCalledWith( + ctx.res.send, + '"email","last_logged_in_at","last_active_at","managed","sso"\n"mock-email-1@foo.com","2020-08-09T12:43:11.467Z","2021-08-09T12:43:11.467Z",false,false\n"mock-email-2@foo.com","2020-05-20T10:41:11.407Z","2021-05-20T10:41:11.407Z",false,false\n"mock-email-3@foo.com","2021-08-10T10:41:11.407Z","2021-08-20T10:41:11.407Z",false,false\n"mock-email-4@foo.com","2021-01-01T10:41:11.407Z","2021-01-02T10:41:11.407Z",true,false\n"mock-email-5@foo.com","2023-01-01T10:41:11.407Z","2023-01-02T10:41:11.407Z",false,true\n"mock-email-6@foo.com","2024-01-01T10:41:11.407Z","2024-01-02T10:41:11.407Z",true,true' ) }) }) @@ -409,8 +522,8 @@ describe('UserMembershipController', function () { ctx.req.params.id = 'abc' }) - it('renders view', function (ctx) { - return new Promise(resolve => { + it('renders view', async function (ctx) { + await new Promise(resolve => { ctx.UserMembershipController.new(ctx.req, { render: (viewPath, data) => { expect(data.entityName).to.eq('publisher') @@ -429,8 +542,8 @@ describe('UserMembershipController', function () { ctx.req.params.id = 123 }) - it('creates institution', function (ctx) { - return new Promise(resolve => { + it('creates institution', async function (ctx) { + await new Promise(resolve => { ctx.UserMembershipController.create(ctx.req, { redirect: path => { expect(path).to.eq(EntityConfigs.institution.pathsFor(123).index) diff --git a/services/web/test/unit/src/UserMembership/UserMembershipHandlerTests.js b/services/web/test/unit/src/UserMembership/UserMembershipHandlerTests.js index f7b665aecd..3bf74c0ab7 100644 --- a/services/web/test/unit/src/UserMembership/UserMembershipHandlerTests.js +++ b/services/web/test/unit/src/UserMembership/UserMembershipHandlerTests.js @@ -48,7 +48,7 @@ describe('UserMembershipHandler', function () { this.UserMembershipViewModel = { promises: { - buildAsync: sinon.stub().resolves({ _id: 'mock-member-id' }), + buildAsync: sinon.stub().resolves([{ _id: 'mock-member-id' }]), }, build: sinon.stub().returns(this.newUser), } @@ -118,26 +118,26 @@ describe('UserMembershipHandler', function () { this.subscription, EntityConfigs.group ) - const expectedCallcount = - this.subscription.member_ids.length + - this.subscription.invited_emails.length + - this.subscription.teamInvites.length expect( - this.UserMembershipViewModel.promises.buildAsync.callCount - ).to.equal(expectedCallcount) + this.UserMembershipViewModel.promises.buildAsync + ).to.be.calledOnceWith( + this.subscription.invited_emails.concat( + this.subscription.teamInvites[0].email, + this.subscription.member_ids + ) + ) }) }) - describe('group mamagers', function () { + describe('group managers', function () { it('build view model for all managers', async function () { await this.UserMembershipHandler.promises.getUsers( this.subscription, EntityConfigs.groupManagers ) - const expectedCallcount = this.subscription.manager_ids.length expect( - this.UserMembershipViewModel.promises.buildAsync.callCount - ).to.equal(expectedCallcount) + this.UserMembershipViewModel.promises.buildAsync + ).to.be.calledOnceWith(this.subscription.manager_ids) }) }) @@ -147,11 +147,9 @@ describe('UserMembershipHandler', function () { this.institution, EntityConfigs.institution ) - - const expectedCallcount = this.institution.managerIds.length expect( - this.UserMembershipViewModel.promises.buildAsync.callCount - ).to.equal(expectedCallcount) + this.UserMembershipViewModel.promises.buildAsync + ).to.be.calledOnceWith(this.institution.managerIds) }) }) }) diff --git a/services/web/test/unit/src/UserMembership/UserMembershipViewModelTests.js b/services/web/test/unit/src/UserMembership/UserMembershipViewModelTests.js index a8ce9d158f..c5e21a5f48 100644 --- a/services/web/test/unit/src/UserMembership/UserMembershipViewModelTests.js +++ b/services/web/test/unit/src/UserMembership/UserMembershipViewModelTests.js @@ -25,7 +25,7 @@ const { describe('UserMembershipViewModel', function () { beforeEach(function () { - this.UserGetter = { getUser: sinon.stub() } + this.UserGetter = { getUsers: sinon.stub() } this.UserMembershipViewModel = SandboxedModule.require(modulePath, { requires: { 'mongodb-legacy': { ObjectId }, @@ -87,9 +87,10 @@ describe('UserMembershipViewModel', function () { }) it('build email', function (done) { + this.UserGetter.getUsers.yields(null, []) return this.UserMembershipViewModel.buildAsync( - this.email, - (error, viewModel) => { + [this.email], + (error, [viewModel]) => { assertCalledWith(this.UserMembershipViewModel.build, this.email) return done() } @@ -97,9 +98,10 @@ describe('UserMembershipViewModel', function () { }) it('build user', function (done) { + this.UserGetter.getUsers.yields(null, []) return this.UserMembershipViewModel.buildAsync( - this.user, - (error, viewModel) => { + [this.user], + (error, [viewModel]) => { assertCalledWith(this.UserMembershipViewModel.build, this.user) return done() } @@ -107,30 +109,34 @@ describe('UserMembershipViewModel', function () { }) it('build user id', function (done) { - this.UserGetter.getUser.yields(null, this.user) + const user = { + ...this.user, + _id: new ObjectId(), + } + this.UserGetter.getUsers.yields(null, [user]) return this.UserMembershipViewModel.buildAsync( - new ObjectId(), - (error, viewModel) => { + [user._id], + (error, [viewModel]) => { expect(error).not.to.exist assertNotCalled(this.UserMembershipViewModel.build) - expect(viewModel._id).to.equal(this.user._id) - expect(viewModel.email).to.equal(this.user.email) - expect(viewModel.first_name).to.equal(this.user.first_name) + expect(viewModel._id.toString()).to.equal(user._id.toString()) + expect(viewModel.email).to.equal(user.email) + expect(viewModel.first_name).to.equal(user.first_name) expect(viewModel.invite).to.equal(false) expect(viewModel.email).to.exist expect(viewModel.enrollment).to.exist - expect(viewModel.enrollment).to.deep.equal(this.user.enrollment) + expect(viewModel.enrollment).to.deep.equal(user.enrollment) return done() } ) }) it('build user id with error', function (done) { - this.UserGetter.getUser.yields(new Error('nope')) + this.UserGetter.getUsers.yields(new Error('nope'), []) const userId = new ObjectId() return this.UserMembershipViewModel.buildAsync( - userId, - (error, viewModel) => { + [userId], + (error, [viewModel]) => { expect(error).not.to.exist assertNotCalled(this.UserMembershipViewModel.build) expect(viewModel._id).to.equal(userId.toString()) diff --git a/services/web/test/unit/vitest_bootstrap.mjs b/services/web/test/unit/vitest_bootstrap.mjs index 5a39b2d587..e00720c5d6 100644 --- a/services/web/test/unit/vitest_bootstrap.mjs +++ b/services/web/test/unit/vitest_bootstrap.mjs @@ -36,6 +36,14 @@ vi.mock('@overleaf/logger', async () => { }) beforeEach(ctx => { + // This function is a utility to duplicate the behaviour of passing `done` in place of `next` in an express route handler. + ctx.rejectOnError = reject => { + return err => { + if (err) { + reject(err) + } + } + } ctx.logger = logger }) diff --git a/services/web/tsconfig.json b/services/web/tsconfig.json index 049642f997..63922ae9a7 100644 --- a/services/web/tsconfig.json +++ b/services/web/tsconfig.json @@ -1,13 +1,14 @@ { "compilerOptions": { "target": "esnext" /* Specify ECMAScript target version */, - "module": "es2020" /* Specify module code generation */, + "module": "esnext" /* Specify module code generation */, "allowJs": true /* Allow JavaScript files to be compiled. */, // "checkJs": true /* Report errors in .js files. */, "jsx": "preserve" /* Specify JSX code generation */, "noEmit": true /* Do not emit outputs. */, "strict": true /* Enable all strict type-checking options. */, "moduleResolution": "node" /* Specify module resolution strategy */, + "isolatedModules": true, "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, "skipLibCheck": true /* Skip type checking of declaration files. */, "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */, diff --git a/services/web/types/admin-capabilities.ts b/services/web/types/admin-capabilities.ts new file mode 100644 index 0000000000..7d87c77a15 --- /dev/null +++ b/services/web/types/admin-capabilities.ts @@ -0,0 +1,3 @@ +export type AdminCapability = 'modify-user-email' | 'view-project' + +export type AdminRole = 'engineering' diff --git a/services/web/types/admin/subscription.ts b/services/web/types/admin/subscription.ts index 811ebf54bf..2d23da20f2 100644 --- a/services/web/types/admin/subscription.ts +++ b/services/web/types/admin/subscription.ts @@ -18,6 +18,7 @@ export type Subscription = { groupPlan: boolean customAccount: boolean ssoConfig: SSOConfig + domainCaptureEnabled?: boolean managedUsersEnabled: boolean v1_id: number salesforce_id: string @@ -25,4 +26,7 @@ export type Subscription = { paymentProvider: | RecurlyAdminClientPaymentProvider | StripeAdminClientPaymentProvider + features: { + domainCapture?: boolean + } } diff --git a/services/web/types/capabilities.ts b/services/web/types/capabilities.ts new file mode 100644 index 0000000000..961c554cfa --- /dev/null +++ b/services/web/types/capabilities.ts @@ -0,0 +1,11 @@ +export type Capability = + | 'add-secondary-email' + | 'change-password' + | 'chat' + | 'delete-own-account' + | 'dropbox' + | 'endorse-email' + | 'join-subscription' + | 'leave-group-subscription' + | 'start-subscription' + | 'use-ai' diff --git a/services/web/types/cobranding.ts b/services/web/types/cobranding.ts index dc6822be84..dc6ae75964 100644 --- a/services/web/types/cobranding.ts +++ b/services/web/types/cobranding.ts @@ -8,4 +8,5 @@ export type Cobranding = { partner?: string brandedMenu?: boolean submitBtnHtml?: string + submitBtnHtmlNoBreaks?: string } diff --git a/services/web/types/css-properties-with-variables.tsx b/services/web/types/css-properties-with-variables.tsx new file mode 100644 index 0000000000..fe0e85902a --- /dev/null +++ b/services/web/types/css-properties-with-variables.tsx @@ -0,0 +1,4 @@ +import { CSSProperties } from 'react' + +export type CSSPropertiesWithVariables = CSSProperties & + Record<`--${string}`, number | string> diff --git a/services/web/types/exposed-settings.ts b/services/web/types/exposed-settings.ts index 9656441fa9..f9d929d13a 100644 --- a/services/web/types/exposed-settings.ts +++ b/services/web/types/exposed-settings.ts @@ -25,6 +25,7 @@ export type ExposedSettings = { isOverleaf: boolean maxEntitiesPerProject: number projectUploadTimeout: number + propensityId?: string maxUploadSize: number recaptchaDisabled: { invite: boolean diff --git a/services/web/types/project/dashboard/survey.d.ts b/services/web/types/project/dashboard/survey.d.ts index f43b454196..2dc3d9b7ec 100644 --- a/services/web/types/project/dashboard/survey.d.ts +++ b/services/web/types/project/dashboard/survey.d.ts @@ -1,6 +1,7 @@ export type Survey = { name: string - preText: string - linkText: string + title: string + text: string + cta?: string url: string } diff --git a/services/web/types/review-panel/review-panel.ts b/services/web/types/review-panel/review-panel.ts index 200f904430..cba5490ab0 100644 --- a/services/web/types/review-panel/review-panel.ts +++ b/services/web/types/review-panel/review-panel.ts @@ -20,6 +20,6 @@ export interface ReviewPanelCommentThreadMessage { content: string id: CommentId timestamp: Date - user: ReviewPanelUser + user?: ReviewPanelUser user_id: UserId } diff --git a/services/web/types/stripe/metadata.ts b/services/web/types/stripe/metadata.ts new file mode 100644 index 0000000000..17861d3d16 --- /dev/null +++ b/services/web/types/stripe/metadata.ts @@ -0,0 +1,14 @@ +import Stripe from 'stripe' +import { RecurlyPlanCode } from '../subscription/plan' + +type MetadataPlanCode = Exclude< + RecurlyPlanCode, + | 'professional_free_trial_7_days' + | 'student_free_trial_7_days' + | 'collaborator_free_trial_7_days' +> + +export type ProductMetadata = Stripe.Metadata & { + planCode: MetadataPlanCode + addOnCode?: Extract<RecurlyPlanCode, 'assistant'> +} diff --git a/services/web/types/stripe/region.ts b/services/web/types/stripe/region.ts new file mode 100644 index 0000000000..b450afa761 --- /dev/null +++ b/services/web/types/stripe/region.ts @@ -0,0 +1 @@ +export type StripeRegion = 'us' | 'uk' diff --git a/services/web/types/stripe/webhook-event.ts b/services/web/types/stripe/webhook-event.ts index f6e36b8b0a..f0a08284ed 100644 --- a/services/web/types/stripe/webhook-event.ts +++ b/services/web/types/stripe/webhook-event.ts @@ -22,6 +22,7 @@ export type CustomerSubscriptionUpdatedWebhookEvent = { }, ] } + status?: Stripe.Subscription.Status } } } @@ -53,6 +54,15 @@ export type InvoicePaidWebhookEvent = { data: { object: Stripe.Invoice } + request: Stripe.Event.Request +} + +export type PaymentIntentPaymentFailedWebhookEvent = { + type: 'payment_intent.payment_failed' + data: { + object: Stripe.PaymentIntent + } + request: Stripe.Event.Request } export type CustomerSubscriptionWebhookEvent = @@ -63,3 +73,4 @@ export type CustomerSubscriptionWebhookEvent = export type WebhookEvent = | CustomerSubscriptionWebhookEvent | InvoicePaidWebhookEvent + | PaymentIntentPaymentFailedWebhookEvent diff --git a/services/web/types/subscription/analytics-event.ts b/services/web/types/subscription/analytics-event.ts new file mode 100644 index 0000000000..98ce12fdf1 --- /dev/null +++ b/services/web/types/subscription/analytics-event.ts @@ -0,0 +1,56 @@ +import { CurrencyCode } from './currency' +import { + PaymentProvider, + StripePaymentProviderService, +} from './dashboard/subscription' +import { RecurlyPlanCode } from './plan' + +type PaymentPageFormSubmitEventBaseSegmentation = { + currencyCode: CurrencyCode + plan_code?: string + coupon_code: string + isPaypal: boolean + upgradeType: 'standalone' + referrer?: string +} + +type PaymentPageFormSubmitEventStripeSegmentation = + PaymentPageFormSubmitEventBaseSegmentation & { + payment_provider: StripePaymentProviderService + stripe_price_id: string + stripe_price_lookup_key: string + } + +type PaymentPageFormSubmitEventRecurlySegmentation = + PaymentPageFormSubmitEventBaseSegmentation & { + payment_provider: Exclude< + PaymentProvider['service'], + 'stripe-us' | 'stripe-uk' + > + } + +export type PaymentPageFormSubmitEventSegmentation = + | PaymentPageFormSubmitEventStripeSegmentation + | PaymentPageFormSubmitEventRecurlySegmentation + +type PaymentPageFormSuccessEventBaseSegmentation = { + plan?: RecurlyPlanCode + upgradeType: 'standalone' + referrer?: string +} + +type PaymentPageFormSuccessEventStripeSegmentation = + PaymentPageFormSuccessEventBaseSegmentation & { + payment_provider: StripePaymentProviderService + stripe_price_id: string + stripe_price_lookup_key: string + } + +type PaymentPageFormSuccessEventRecurlySegmentation = + PaymentPageFormSuccessEventBaseSegmentation & { + payment_provider: 'recurly' + } + +export type PaymentPageFormSuccessEventSegmentation = + | PaymentPageFormSuccessEventStripeSegmentation + | PaymentPageFormSuccessEventRecurlySegmentation diff --git a/services/web/types/subscription/dashboard/subscription.ts b/services/web/types/subscription/dashboard/subscription.ts index db17b25684..694e5b948c 100644 --- a/services/web/types/subscription/dashboard/subscription.ts +++ b/services/web/types/subscription/dashboard/subscription.ts @@ -8,7 +8,12 @@ import { } from '../plan' import { User } from '../../user' -export type SubscriptionState = 'active' | 'canceled' | 'expired' | 'paused' +export type SubscriptionState = + | 'active' + | 'canceled' + | 'expired' + | 'paused' + | 'past_due' // when puchasing a new add-on in recurly, we only need to provide the code export type PurchasingAddOnCode = { @@ -103,6 +108,10 @@ export type MemberGroupSubscription = Omit<GroupSubscription, 'admin_id'> & { } type PaymentProviderService = 'stripe-us' | 'stripe-uk' | 'recurly' +export type StripePaymentProviderService = Exclude< + PaymentProviderService, + 'recurly' +> export type PaymentProvider = { service: PaymentProviderService diff --git a/services/web/types/web-module.ts b/services/web/types/web-module.ts index 406d03d85b..298f430df2 100644 --- a/services/web/types/web-module.ts +++ b/services/web/types/web-module.ts @@ -1,3 +1,5 @@ +import type { RequestHandler } from 'express' + type LinkedFileAgent = { createLinkedFile: ( projectId: string, @@ -41,6 +43,11 @@ export type WebModule = { privateApiRouter?: any, publicApiRouter?: any ) => void + applyNonCsrfRouter?: ( + webRouter: any, + privateApiRouter?: any, + publicApiRouter?: any + ) => void } nonCsrfRouter?: { apply: (webRouter: any, privateApiRouter: any, publicApiRouter: any) => void @@ -49,7 +56,7 @@ export type WebModule = { [name: string]: (args: any[]) => void } middleware?: { - [name: string]: (req: any, res: any, next: any) => void + [name: string]: RequestHandler } sessionMiddleware?: (webRouter: any, options: any) => void start?: () => Promise<void> @@ -57,4 +64,5 @@ export type WebModule = { linkedFileAgents?: { [name: string]: () => LinkedFileAgent } + viewIncludes?: Record<string, string[]> } diff --git a/services/web/types/window.ts b/services/web/types/window.ts index d2856e7179..5688faa9a4 100644 --- a/services/web/types/window.ts +++ b/services/web/types/window.ts @@ -25,5 +25,7 @@ declare global { } ga?: (...args: any) => void gtag?: (...args: any) => void + + propensity?: (propensityId?: string) => void } } diff --git a/services/web/webpack.config.js b/services/web/webpack.config.js index 4b8ec18113..94144eeea2 100644 --- a/services/web/webpack.config.js +++ b/services/web/webpack.config.js @@ -26,6 +26,7 @@ const entryPoints = { 'main-light-style': './frontend/stylesheets/main-light-style.less', 'main-style-bootstrap-5': './frontend/stylesheets/bootstrap-5/main-style.scss', + tracking: './frontend/js/infrastructure/tracking.ts', } // Add entrypoints for each "page"